@cmodjs/core 0.1.8 → 0.1.10

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 (86) hide show
  1. package/docs/commands.md +2 -2
  2. package/docs/hooks.md +62 -6
  3. package/docs/index.md +7 -4
  4. package/docs/install-steps.md +3 -3
  5. package/docs/jobs.md +5 -4
  6. package/docs/mod.md +57 -7
  7. package/docs/testing.md +25 -9
  8. package/docs/ui.md +134 -5
  9. package/jobs/check.js +2 -2
  10. package/jobs/context.d.ts.map +1 -1
  11. package/jobs/context.js +2 -0
  12. package/jobs/permissions/decide-permission.js +2 -2
  13. package/jobs/permissions/find-project-scope.d.ts +1 -1
  14. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  15. package/jobs/permissions/find-project-scope.js +9 -9
  16. package/jobs/permissions/match-target.d.ts.map +1 -1
  17. package/jobs/permissions/match-target.js +4 -3
  18. package/mod.d.ts +16 -6
  19. package/mod.d.ts.map +1 -1
  20. package/package.json +2 -1
  21. package/path.d.ts +2 -0
  22. package/path.d.ts.map +1 -0
  23. package/path.js +1 -0
  24. package/register.d.ts.map +1 -1
  25. package/register.js +8 -2
  26. package/runtime/claude.d.ts +4 -1
  27. package/runtime/claude.d.ts.map +1 -1
  28. package/runtime/hooks.d.ts +10 -2
  29. package/runtime/hooks.d.ts.map +1 -1
  30. package/runtime/hooks.js +56 -18
  31. package/runtime/lifecycle.d.ts.map +1 -1
  32. package/runtime/lifecycle.js +12 -2
  33. package/runtime/ui.d.ts.map +1 -1
  34. package/runtime/ui.js +42 -26
  35. package/shell.d.ts +2 -0
  36. package/shell.d.ts.map +1 -0
  37. package/shell.js +1 -0
  38. package/src/jobs/check.ts +2 -2
  39. package/src/jobs/context.ts +2 -0
  40. package/src/jobs/permissions/decide-permission.ts +2 -2
  41. package/src/jobs/permissions/find-project-scope.ts +11 -10
  42. package/src/jobs/permissions/match-target.ts +4 -3
  43. package/src/mod.ts +24 -4
  44. package/src/path.ts +1 -0
  45. package/src/register.ts +8 -2
  46. package/src/runtime/claude.ts +11 -1
  47. package/src/runtime/hooks.ts +62 -23
  48. package/src/runtime/lifecycle.ts +11 -4
  49. package/src/runtime/ui.ts +39 -26
  50. package/src/shell.ts +1 -0
  51. package/src/testing/fake-claude.ts +14 -5
  52. package/src/testing/fake-elements.ts +5 -7
  53. package/src/testing/fake-files.ts +5 -5
  54. package/src/testing.ts +83 -11
  55. package/src/ui/components.ts +70 -0
  56. package/src/ui/define-pane.ts +5 -1
  57. package/src/utils/call-effects.ts +12 -11
  58. package/src/utils/parse-shell.ts +28 -36
  59. package/src/utils/paths.ts +4 -19
  60. package/src/vendor.ts +1 -0
  61. package/testing/fake-claude.d.ts +5 -0
  62. package/testing/fake-claude.d.ts.map +1 -1
  63. package/testing/fake-claude.js +13 -3
  64. package/testing/fake-elements.d.ts +5 -8
  65. package/testing/fake-elements.d.ts.map +1 -1
  66. package/testing/fake-elements.js +6 -6
  67. package/testing/fake-files.js +5 -5
  68. package/testing.d.ts +4 -2
  69. package/testing.d.ts.map +1 -1
  70. package/testing.js +65 -9
  71. package/ui/components.d.ts +48 -0
  72. package/ui/components.d.ts.map +1 -0
  73. package/ui/components.js +56 -0
  74. package/ui/define-pane.d.ts +5 -1
  75. package/ui/define-pane.d.ts.map +1 -1
  76. package/utils/call-effects.d.ts.map +1 -1
  77. package/utils/call-effects.js +12 -11
  78. package/utils/parse-shell.d.ts +5 -9
  79. package/utils/parse-shell.d.ts.map +1 -1
  80. package/utils/parse-shell.js +5 -10
  81. package/utils/paths.d.ts +0 -3
  82. package/utils/paths.d.ts.map +1 -1
  83. package/utils/paths.js +3 -18
  84. package/vendor.d.ts +1 -0
  85. package/vendor.d.ts.map +1 -1
  86. package/vendor.js +209 -12
package/docs/commands.md CHANGED
@@ -49,9 +49,9 @@ Runs every check this machine can run on the mod at `path` (default: the current
49
49
  2. Checks its layout.
50
50
  3. Checks that each step runs a script.
51
51
  4. Checks its imports.
52
- 5. Looks for prebuilt binaries.
52
+ 5. Looks for prebuilt binaries outside `cli/`.
53
53
  6. Validates it with Claude Code, `claude plugin validate --strict`.
54
- 7. Type-checks it with `tsc`. It skips this until `.claude-plugin/types/` exists, which Claude Code writes the first time a session loads the mod: run `cmod link`, start `claude` once, then run `cmod check` again.
54
+ 7. Type-checks it with `tsc`. When `.claude-plugin/types/` is missing, as in a fresh clone or in CI, Claude Code writes it first: cmod loads the mod in one `claude -p` run with a config folder of its own and no model to reach, so the run sends nothing.
55
55
  8. Lints it with `oxlint`.
56
56
  9. Runs, with `bun test`, every test file that does not import `claude-code/testing`.
57
57
  10. Runs, with `claude plugin test`, the test files that import `claude-code/testing`. It skips this when none does.
package/docs/hooks.md CHANGED
@@ -7,12 +7,29 @@
7
7
  ```ts
8
8
  mod.on<E extends ModEvent>(event: E, hook: ModHook<E>): void
9
9
 
10
- type ModHook<E> = (input: HookInputs[E]) => HookAnswer | void | Promise<HookAnswer | void>
10
+ type ModHook<E> = (input: HookInput<E>) => HookAnswer | void | Promise<HookAnswer | void>
11
11
  ```
12
12
 
13
13
  Add hooks in `setup`. Each `mod.on` adds one more hook. Several hooks on one event all run.
14
14
 
15
- A hook on `PreToolUse` or `PermissionRequest` takes part in Claude Code's permission check, so the mod's `hooks/register.ts` calls `registerPermissionCheck(addHook)` after `registerMod` ([mod.md](mod.md)). Without it the mod does not start.
15
+ `HookInput<E>`, exported from `mod.js`, is the input a hook on `E` gets, for a hook that lives in a function of its own:
16
+
17
+ ```ts
18
+ import type { HookAnswer, HookInput, Mod } from '../node_modules/@cmodjs/core/mod.js'
19
+
20
+ export async function addBranch(mod: Mod, input: HookInput<'SessionStart'>): Promise<HookAnswer | undefined> {
21
+ const { stdout } = await mod.process.run(['git', 'branch', '--show-current'], { cwd: input.cwd })
22
+ return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `The git branch is ${stdout.trim()}.` } }
23
+ }
24
+ ```
25
+
26
+ A `PreToolUse` hook runs on Claude Code's `tool.call`, before Claude Code checks the call's permission. It rewrites the call's input, adds context to it, or denies it, and Claude Code's permission check sees the rewritten input. A `permissionDecision` of `'allow'` or `'ask'` decides the call's permission, so it takes effect in Claude Code's permission check, and only when the mod's `hooks/register.ts` calls `registerPermissionCheck(addHook)` after `registerMod` ([mod.md](mod.md)):
27
+
28
+ - `'allow'` runs the call without asking the person, and `'ask'` asks them, with `permissionDecisionReason` as the question's reason.
29
+ - A deny from a settings rule or another hook still denies the call, and the mod's own `permissions` job can still ask or deny.
30
+ - Without `registerPermissionCheck`, an `'allow'` or `'ask'` denies the call with `<mod>: the PreToolUse hook answered permissionDecision "allow", so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.`
31
+
32
+ A hook on `PermissionRequest` takes part in that check too, so a mod with one does not start without `registerPermissionCheck`.
16
33
 
17
34
  ```ts
18
35
  import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
@@ -31,7 +48,7 @@ export const safeDelete = defineMod({
31
48
 
32
49
  ## Every ModEvent
33
50
 
34
- `ModEvent` names the 17 events a mod hooks. The input of each is the hook input a `settings.json` hook reads for that event, typed as `ClassicHookInputs['<event>']` in the `claude-code` module, with the changes this table names.
51
+ `ModEvent` names the 18 events a mod hooks. The input of each is the hook input a `settings.json` hook reads for that event, typed as `ClassicHookInputs['<event>']` in the `claude-code` module, with the changes this table names.
35
52
 
36
53
  | Event | Input changes |
37
54
  | --- | --- |
@@ -52,9 +69,17 @@ export const safeDelete = defineMod({
52
69
  | `Stop` | none |
53
70
  | `StopFailure` | none |
54
71
  | `CwdChanged` | no `transcript_path` |
72
+ | `FileChanged` | none |
55
73
 
56
74
  cmod raises `CwdChanged` itself, never Claude Code. It raises it when the working folder changes: after `/cd`, after a Bash or PowerShell call, and at a prompt. `mod.cwd` and `mod.projectRoot` already hold the new folder when the hook runs.
57
75
 
76
+ Claude Code raises `FileChanged` when a file it watches changes, with the file's `file_path` and `event`: `'change'`, `'add'`, or `'unlink'`. It watches the paths a `SessionStart` hook answers in `watchPaths`:
77
+
78
+ ```ts
79
+ mod.on('SessionStart', () => ({ hookSpecificOutput: { hookEventName: 'SessionStart', watchPaths: [`${mod.dataFolder}/history`] } }))
80
+ mod.on('FileChanged', ({ file_path, event }) => mod.ui.toast(`${file_path}: ${event}`))
81
+ ```
82
+
58
83
  ## files: what a tool call reads and changes
59
84
 
60
85
  The input of `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` has `files: { read: string[]; changed: string[] }`, absolute paths that cmod works out from the call:
@@ -65,6 +90,37 @@ The input of `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` has `files: {
65
90
  - `read` lists only paths that are files now. `changed` lists every path the call writes, whether it exists or not.
66
91
  - When cmod cannot work the files out, both lists are empty, and the reason goes to Claude Code's debug log.
67
92
 
93
+ ## shell.js and path.js: read a command line
94
+
95
+ `shell.js` reads a Bash line the way cmod does for `files`, and `path.js` is `node:path`'s POSIX functions, which a mod cannot import from Node:
96
+
97
+ ```ts
98
+ parseShell(line: string): ParsedShell
99
+
100
+ type ParsedShell = { commands: ShellCommand[]; reads: string[]; writes: string[]; fetches: string[]; isFullyParsed: boolean }
101
+ type ShellCommand = { argv: [string, ...string[]]; folder: string }
102
+
103
+ resolve(...paths): string join(...paths): string dirname(path): string basename(path, suffix?): string
104
+ extname(path): string relative(from, to): string isAbsolute(path): boolean normalize(path): string
105
+ ```
106
+
107
+ - `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`.
108
+ - `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(input.cwd, folder)` makes it absolute.
109
+ - `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.
110
+ - `resolve` has no working folder to start from in Claude Code's hooks, so pass an absolute folder first, such as `resolve(mod.cwd, path)`.
111
+
112
+ ```ts
113
+ import { basename, resolve } from '../node_modules/@cmodjs/core/path.js'
114
+ import { parseShell } from '../node_modules/@cmodjs/core/shell.js'
115
+
116
+ mod.on('PreToolUse', (input) => {
117
+ if (input.tool_name !== 'Bash') return
118
+ for (const { argv, folder } of parseShell(String(input.tool_input['command'])).commands) {
119
+ if (basename(argv[0]) === 'trace') mod.ui.toast(`trace runs in ${resolve(input.cwd, folder)}`)
120
+ }
121
+ })
122
+ ```
123
+
68
124
  ## HookAnswer: what a hook can answer
69
125
 
70
126
  Return nothing to leave the event as it is. Return a `HookAnswer` to change it. The fields are the ones a `settings.json` hook writes as JSON:
@@ -108,12 +164,12 @@ Each event reads only some `hookSpecificOutput` fields:
108
164
  | --- | --- |
109
165
  | `SessionStart` | `additionalContext`, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills` |
110
166
  | `UserPromptSubmit` | `additionalContext`, `sessionTitle`, `suppressOriginalPrompt` |
111
- | `PreToolUse` | `additionalContext`, `permissionDecision`, `permissionDecisionReason`, `updatedInput` |
167
+ | `PreToolUse` | `additionalContext`, `permissionDecision` (`'allow'` and `'ask'` need `registerPermissionCheck`), `permissionDecisionReason`, `updatedInput` |
112
168
  | `PermissionRequest` | `decision` |
113
169
  | `PermissionDenied` | `retry` |
114
170
  | `PostToolUse` | `additionalContext`, `updatedToolOutput`, `updatedMCPToolOutput` |
115
171
  | `PostToolUseFailure`, `PostToolBatch`, `SubagentStart`, `SubagentStop`, `Stop` | `additionalContext` |
116
- | `SessionEnd`, `InstructionsLoaded`, `Notification`, `PreCompact`, `StopFailure`, `CwdChanged` | none |
172
+ | `SessionEnd`, `InstructionsLoaded`, `Notification`, `PreCompact`, `StopFailure`, `CwdChanged`, `FileChanged` | none |
117
173
 
118
174
  A field the event does not read is an error: `it answered hookSpecificOutput.<field>, which <event> does not read. Remove it from the answer.` A flag set to `false`, such as `retry: false`, counts as unset.
119
175
 
@@ -122,7 +178,7 @@ A field the event does not read is an error: `it answered hookSpecificOutput.<fi
122
178
  Every mod's hooks and Claude Code's own `settings.json` hooks answer the same event.
123
179
 
124
180
  - `additionalContext` from every hook reaches Claude, each in turn.
125
- - On `PreToolUse` the strictest decision wins: `deny` over `ask` over `allow`. A mod's `allow` never overrides a `deny` from another hook.
181
+ - `PreToolUse` hooks run one inside the next, in the order mods and `mod.on` added them. A deny stops the call there, so no hook after it runs. `updatedInput` replaces the tool's input for every hook after it and for Claude Code. `additionalContext` reaches Claude with the call's result, and not at all when Claude Code refuses the call.
126
182
  - For every other field, the mod's answer replaces the answer of the hooks beneath it.
127
183
 
128
184
  ```ts
package/docs/index.md CHANGED
@@ -32,9 +32,9 @@ The template is a working example to replace. It counts the prompts of the sessi
32
32
 
33
33
  A mod imports `@cmodjs/core` by path, such as `../node_modules/@cmodjs/core/mod.js`, because Claude Code loads a plugin's own files only. Types such as `RenderElement` and `On` come from the `claude-code` module.
34
34
 
35
- ## Type-check after the first session
35
+ ## Claude Code's types
36
36
 
37
- Claude Code writes `.claude-plugin/types/` the first time a session loads the mod. That folder holds the `tsconfig.json` the mod's own extends, the `claude-code` module, and the `h` JSX global. Until it exists, `tsc` fails, and `cmod check` skips its type check. Run `cmod link`, start `claude` once, then type-check. A project plugin needs no link: start `claude` in its repository and trust it.
37
+ `.claude-plugin/types/` holds the `tsconfig.json` the mod's own extends, the `claude-code` module, and the `h` JSX global. Claude Code writes it when it loads the mod, and git ignores it. `cmod check` has Claude Code write it when it is missing, so a fresh clone and CI type-check too. Until it exists, `tsc` alone fails, so run `cmod check` once first.
38
38
 
39
39
  ## Where a mod runs
40
40
 
@@ -72,12 +72,15 @@ A mod imports from the files below, and every other file in this package is inte
72
72
  | --- | --- | --- |
73
73
  | `mod.js` | `defineMod`, `ModDefinition`, `Mod`, `JobContext`, `Job`, `ProgressStep`, `PaneHandle`, `longestMs`, `messageOf` | [mod.md](mod.md), [jobs.md](jobs.md), [ui.md](ui.md) |
74
74
  | `mod.js` | `Claude`, `RoutedEvent`, `RoutedHook`, `ToolCalls` | [jobs.md](jobs.md) |
75
- | `mod.js` | `ModEvent`, `ModHook`, `HookAnswer` | [hooks.md](hooks.md) |
75
+ | `mod.js` | `ModEvent`, `ModHook`, `HookInput`, `HookAnswer` | [hooks.md](hooks.md) |
76
76
  | `mod.js` | `notInstalled` | [dependencies.md](dependencies.md) |
77
77
  | `register.js` | `registerMod`, `registerPermissionCheck` | [mod.md](mod.md) |
78
+ | `shell.js` | `parseShell`, `ParsedShell`, `ShellCommand` | [hooks.md](hooks.md) |
79
+ | `path.js` | `resolve`, `join`, `dirname`, `basename`, `extname`, `relative`, `isAbsolute`, `normalize` | [hooks.md](hooks.md) |
78
80
  | `testing.js` | `testMod`, `TestOptions`, `TestInput`, `TestedMod`, `Fakes`, `Shown`, `TestCall` | [testing.md](testing.md) |
79
81
  | `ui/define-pane.js` | `definePane`, `Pane` | [ui.md](ui.md) |
80
82
  | `ui/elements.js` | `Box`, `Text`, `Button`, `Link`, `Code`, `Markdown`, `Input`, `Select`, `Image`, `drawWith` | [ui.md](ui.md) |
83
+ | `ui/components.js` | `Tabs`, `Tab`, `Split`, `Panel`, `Toggle`, `Tooltip`, `Help`, `Dialog`, `Pagination`, `ProgressBar` | [ui.md](ui.md) |
81
84
  | `ui/slots.js` | `slots`, `Slot`, `SlotProps` | [ui.md](ui.md) |
82
85
  | `ui/markdown.js` | `markdownSlots`, `markdownBlocks`, `MarkdownKind`, `MarkdownReader`, `MarkdownPiece` | [ui.md](ui.md) |
83
86
  | `jobs/slash-command.js` | `slashCommand`, `Reply` | [jobs.md](jobs.md) |
@@ -98,4 +101,4 @@ A mod imports from the files below, and every other file in this package is inte
98
101
 
99
102
  ## Check your work
100
103
 
101
- Run `cmod check` in the mod's folder. It installs the packages, checks the layout and the imports, validates the mod with Claude Code, type-checks it once `.claude-plugin/types/` exists, lints it, and runs its tests. Each failure names its fix. [commands.md](commands.md) lists every step.
104
+ Run `cmod check` in the mod's folder. It installs the packages, checks the layout and the imports, validates the mod with Claude Code, type-checks it, lints it, and runs its tests. Each failure names its fix. [commands.md](commands.md) lists every step.
@@ -134,9 +134,9 @@ A mod that builds its own command-line program keeps its source in `cli/`, and c
134
134
  }
135
135
  ```
136
136
 
137
- The program's name is the one `bin` command, or else `name`. A Rust program declares the same keys under `[package.metadata.cmod]` in `cli/Cargo.toml`.
137
+ The program's name is the one `bin` command, or else `name`. A Rust program declares the same keys under `[package.metadata.cmod]` in `cli/Cargo.toml`, and its name is the one `[[bin]]` `name`, or else the `[package]` `name`.
138
138
 
139
- 2. The build writes one file per machine into `output`, each named `<program>-<os>-<arch>`, such as `hello-darwin-arm64`.
139
+ 2. The build writes one file per machine into `output`, each named `<program>-<os>-<arch>`, such as `hello-darwin-arm64`. cmod runs it with `CMOD_MACHINES` set to the machines it needs, separated by spaces: `cmod link` names this machine alone, and `cmod publish` names all four. A build that ignores `CMOD_MACHINES` and builds every machine works too, only slower to link. cmod takes only the files the build wrote in that run, so a build left from an earlier run is never released.
140
140
 
141
141
  3. The mod's own `package.json` names the program: `"cmod": { "program": "hello" }`. `.claude-plugin/plugin.json` names the GitHub `repository`.
142
142
 
@@ -145,7 +145,7 @@ Then:
145
145
  - `cmod link` builds the program and links this machine's build to `~/.local/bin/<program>`.
146
146
  - `cmod publish` builds every machine's file and attaches each to the GitHub release `v<version>`, with a `SHA256SUMS` file. It refuses a `cli/` program that the `package.json` `program` key does not name.
147
147
  - On install, cmod downloads `<repository>/releases/download/v<version>/<program>-<machine>`, checks it against `SHA256SUMS`, runs `<program> --version`, and links it to `~/.local/bin/<program>`.
148
- - cmod refuses to replace a `~/.local/bin/<program>` it did not make, and a program `PATH` already finds somewhere else.
148
+ - cmod refuses to replace a `~/.local/bin/<program>` it did not make, and a program `PATH` finds in a folder ahead of `~/.local/bin`.
149
149
  - `cmod unlink` and the uninstall remove the program.
150
150
 
151
151
  The cmod repository ships the `cmod` program this way. Its `package.json` holds `"cmod": { "program": "cmod" }`, and its `cli/package.json` holds `"cmod": { "build": "bun run build", "output": "dist" }`.
package/docs/jobs.md CHANGED
@@ -361,7 +361,7 @@ type JobContext<State> = {
361
361
 
362
362
  - `claude` reaches Claude Code itself. [The claude members](#the-claude-members) lists each one.
363
363
  - `claude.store` is where cmod saves `mod.state`, under keys that start with `<mod>.`, such as `<mod>.<key>`. A job of your own never writes those keys, or it changes the mod's state behind cmod's back.
364
- - `on` adds a Claude Code hook-module handler, `(e, next) => …`, on a `classic.<ModEvent>` event or on `tool.check`, `tool.call`, `prompt.submit`, `prompt.context`, `command.run`, `session.measure`, `skill.prompt`, `ui.render`, `ui.press`, `ui.close`, or `cmod.call`. The handler calls `next(e)` to pass the event on. A job with a handler on `tool.check`, `classic.PreToolUse`, or `classic.PermissionRequest` needs `registerPermissionCheck` in `hooks/register.ts` ([mod.md](mod.md)).
364
+ - `on` adds a Claude Code hook-module handler, `(e, next) => …`, on a `classic.<ModEvent>` event or on `tool.check`, `tool.call`, `prompt.submit`, `prompt.context`, `command.run`, `session.measure`, `skill.prompt`, `ui.render`, `ui.press`, `ui.scroll`, `ui.close`, or `cmod.call`. The handler calls `next(e)` to pass the event on. A job with a handler on `tool.check` or `classic.PermissionRequest` needs `registerPermissionCheck` in `hooks/register.ts` ([mod.md](mod.md)).
365
365
  - `announce` adds a phrase to the list cmod logs when the mod first starts.
366
366
  - `reserveName` throws `taken` when the mod already reserved that `kind` and `name`. A job calls it to refuse a duplicate.
367
367
  - `toolCalls.agentOf(toolUseId)` resolves `{ agentId?, agentType? }`, the subagent behind a call.
@@ -405,12 +405,11 @@ Each member calls the member of the same name in Claude Code's hooks API, `$`, w
405
405
  | `ui.open(pane)` | Opens a pane, `{ id, title?, focus?, closeOnEscape?, holdToasts?, rows?, columns? }`, and resolves `{ isPlaced }`. |
406
406
  | `ui.close(pane)` | Closes the open pane with that `{ id }`. |
407
407
  | `ui.panes()` | The plugin's open panes: each one's id, title, and whether it is shown, holds the keyboard, and is placed. |
408
+ | `ui.scroll(args)` | What `mod.ui.scroll` calls. |
408
409
  | `ui.resolve(e)` | The element table of the surface a `ui.render` event draws on. |
409
410
  | `process.run(argv, init?)` | Runs a program to its end. `mod.process.run` is built on it. |
410
411
  | `process.spawn(request)` | Starts a program and streams its output. `mod.process.spawn` is built on it. |
411
- | `fs.read`, `fs.write`, `fs.list` | What `mod.fs` calls. A relative path is under the session's working folder. |
412
- | `fs.exists(path)` | Resolves whether the path exists. |
413
- | `fs.stat(path, options?)` | Resolves `{ kind, size, mtimeMs, isLink }`, and `realPath` with `{ resolve: true }`. It rejects for a missing path. |
412
+ | `fs.read`, `fs.write`, `fs.list`, `fs.exists`, `fs.stat` | What `mod.fs` calls. A relative path is under the session's working folder. |
414
413
  | `http.fetch(url, init?)` | What `mod.http.fetch` calls. |
415
414
  | `settings.read(args?)` | What `mod.settings.read` calls. |
416
415
  | `store.get`, `store.set`, `store.delete`, `store.keys` | The plugin's key-value store, where cmod also keeps `mod.state`. |
@@ -423,9 +422,11 @@ Each member calls the member of the same name in Claude Code's hooks API, `$`, w
423
422
  | `session.model()` | The main conversation's model, as `/model` shows it. |
424
423
  | `session.usage()` | When the session began, the context window's fill, the rate-limit windows, and the cost, as the status line has them. |
425
424
  | `session.surfaces()` | Every surface the session draws on, `terminal` first. Empty in a plain `-p` run. |
425
+ | `session.messages(args?)` | What `mod.session.messages` calls. |
426
426
  | `command.register(spec)` | Adds a slash command. `slashCommand` is built on it. |
427
427
  | `tool.register(spec)` | Adds a tool. `tool` is built on it. |
428
428
  | `agent.list()` | The session's subagents and teammates so far. |
429
+ | `agent.spawn(args)` | What `mod.agent.spawn` calls. |
429
430
  | `env.home()` | The `HOME` variable, or `undefined` when it is unset. |
430
431
  | `env.dataHome()` | The `XDG_DATA_HOME` variable, or `undefined`. |
431
432
  | `env.configHome()` | The `CLAUDE_CONFIG_DIR` variable, or `undefined`. |
package/docs/mod.md CHANGED
@@ -51,7 +51,7 @@ When a step fails, the mod does not start. Its progress line above the prompt na
51
51
  - `its state did not load: <error>` when reading the saved values or a `state.json` fails.
52
52
  - `its setup function threw: <error>` when `setup` throws or rejects.
53
53
  - `its open panes did not load: <error>` when Claude Code cannot list the open panes.
54
- - `it decides permissions on <events>, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod` when `setup` adds a hook on `PreToolUse` or `PermissionRequest`, or a job that decides permissions, and `register.ts` does not register the permission check.
54
+ - `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.
55
55
 
56
56
  Each of these says `Fix it, then run /reload-plugins.`
57
57
 
@@ -74,9 +74,9 @@ registerMod<State>(addHook: On, definition: ModDefinition<State>): void
74
74
  registerPermissionCheck(addHook: On): void
75
75
  ```
76
76
 
77
- `registerMod` registers one handler per Claude Code event the mod can use, and routes each event to the mod. It leaves out Claude Code's permission check, the events `tool.check`, `PreToolUse`, and `PermissionRequest`.
77
+ `registerMod` registers one handler per Claude Code event the mod can use, and routes each event to the mod. 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`.
78
78
 
79
- A mod that decides permissions calls `registerPermissionCheck` after `registerMod`. That covers a hook on `PreToolUse` or `PermissionRequest`, the `permissions` job, and a job of your own on `tool.check`:
79
+ 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`:
80
80
 
81
81
  ```ts
82
82
  export function register(addHook: On): void {
@@ -102,11 +102,13 @@ type Mod<State> = {
102
102
  readonly cwd: string
103
103
  on(event, hook): void
104
104
  use(job): Handle
105
- readonly ui: { pane, render, toast, progress, ask }
105
+ readonly ui: { pane, render, toast, progress, ask, scroll }
106
106
  readonly process: { run, spawn }
107
- readonly fs: { read, write, list }
107
+ readonly fs: { read, write, list, exists, stat }
108
108
  readonly http: { fetch }
109
109
  readonly settings: { read }
110
+ readonly session: { messages }
111
+ readonly agent: { spawn }
110
112
  readonly dependencies: CmodDependencies
111
113
  }
112
114
  ```
@@ -120,11 +122,13 @@ type Mod<State> = {
120
122
  | `cwd` | The session's working folder. It follows a `cd` in a Bash or PowerShell call, and `/cd`. | |
121
123
  | `on` | Adds a hook on a Claude Code event. | [hooks.md](hooks.md) |
122
124
  | `use` | Adds a job, such as a slash command or a tool, and returns its handle. | [jobs.md](jobs.md) |
123
- | `ui` | Panes, slot renders, toasts, progress lines, and questions. | [ui.md](ui.md) |
125
+ | `ui` | Panes, slot renders, toasts, progress lines, questions, and scrolling. | [ui.md](ui.md) |
124
126
  | `process` | Runs a program. | below |
125
- | `fs` | Reads and writes files. | below |
127
+ | `fs` | Reads, writes, and looks up files. | below |
126
128
  | `http` | Fetches a URL. | below |
127
129
  | `settings` | Reads Claude Code's settings. | below |
130
+ | `session` | Reads the conversation, or a subagent's. | below |
131
+ | `agent` | Starts a subagent. | below |
128
132
  | `dependencies` | Calls the methods of other mods. | [dependencies.md](dependencies.md) |
129
133
 
130
134
  ### mod.process
@@ -168,12 +172,16 @@ export const branchNote = defineMod({
168
172
  mod.fs.read(path: string): Promise<string>
169
173
  mod.fs.write(path: string, text: string): Promise<void>
170
174
  mod.fs.list(path?: string): Promise<FsEntry[]>
175
+ mod.fs.exists(path: string): Promise<boolean>
176
+ mod.fs.stat(path: string, options?: { resolve: boolean }): Promise<FsStat>
171
177
  ```
172
178
 
173
179
  - A relative path is under the session's working folder. An absolute path is used as given. Text is UTF-8.
174
180
  - `read` rejects a missing file and a file over 4 MiB.
175
181
  - `write` writes the whole file and creates its folders.
176
182
  - `list` lists a folder, the working folder without a path. Each entry is `{ name, kind, size, mtimeMs, isLink }`, and `kind` is `'file'`, `'dir'`, or `'other'`. A symbolic link is `'other'` with `isLink` true.
183
+ - `exists` answers whether the path leads to a file or a folder.
184
+ - `stat` answers `{ kind, size, mtimeMs, isLink }` of what the path leads to, following a symbolic link, and rejects a missing path. With `{ resolve: true }` it also answers `realPath`: the absolute path, every symbolic link followed.
177
185
 
178
186
  A file the mod ships is under the plugin's folder. `mod` has no member that names that folder, so a job of your own reads it from `claude.plugin.root` ([jobs.md](jobs.md#the-claude-members)). A file the mod keeps for itself belongs in `mod.dataFolder`.
179
187
 
@@ -208,6 +216,48 @@ export const pluginCount = defineMod({
208
216
  })
209
217
  ```
210
218
 
219
+ ### mod.session
220
+
221
+ ```ts
222
+ mod.session.messages(): Promise<SessionMessage[]>
223
+ mod.session.messages(args: { agentId?: string; as?: 'api' }): Promise<…>
224
+ ```
225
+
226
+ `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. `{ as: 'api' }` reads it in the Messages API form, `{ role, content }` with the content blocks whole.
227
+
228
+ ```ts
229
+ mod.on('SubagentStop', async ({ agent_id }) => {
230
+ const read = await mod.session.messages({ agentId: agent_id })
231
+ if (Array.isArray(read)) mod.ui.toast(read.at(-1)?.text ?? '')
232
+ })
233
+ ```
234
+
235
+ ### mod.agent
236
+
237
+ ```ts
238
+ mod.agent.spawn(args: { prompt: string; description?; subagentType?; model?; name?; cwd? }): Promise<AgentSpawnResult>
239
+ ```
240
+
241
+ `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.
242
+
243
+ 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:
244
+
245
+ ```ts
246
+ const answers = new Map<string, (answer: string) => void>()
247
+
248
+ mod.on('SubagentStop', (input) => {
249
+ answers.get(input.agent_id)?.(input.last_assistant_message ?? '')
250
+ })
251
+
252
+ mod.on('UserPromptSubmit', async (input) => {
253
+ if (!input.prompt.startsWith('/summarize')) return
254
+ const spawned = await mod.agent.spawn({ prompt: 'Summarize README.md in one line.', subagentType: 'Explore' })
255
+ if (spawned.agentId === undefined) return
256
+ const summary = await new Promise<string>((resolve) => answers.set(spawned.agentId as string, resolve))
257
+ return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: summary } }
258
+ })
259
+ ```
260
+
211
261
  ## messageOf
212
262
 
213
263
  ```ts
package/docs/testing.md CHANGED
@@ -58,7 +58,9 @@ type TestedMod<State> = {
58
58
  lines(slot, props, requestId?): Promise<string[]>
59
59
  type(line: string): Promise<Reply>
60
60
  callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
61
- press(paneId: string, key: string): Promise<void>
61
+ press(paneId: string, key: string, link?: string): Promise<void>
62
+ input(paneId: string, key: string, text: string, kind?: 'submit' | 'change'): Promise<void>
63
+ select(paneId: string, key: string, value: string): Promise<void>
62
64
  moveTo(projectRoot: string, cwd?: string): Promise<void>
63
65
  readonly state: Readonly<State>
64
66
  readonly calls: readonly TestCall[]
@@ -69,7 +71,7 @@ type TestedMod<State> = {
69
71
 
70
72
  ### start
71
73
 
72
- Starts the mod: loads its state, runs `setup`, and adds its hooks, panes, and jobs. `fire`, `lines`, `type`, `callTool`, `press`, and `moveTo` start the mod first, so a test calls `start` only before it reads `state` or `shown` with nothing else to run. It rejects with the reason when the mod does not start.
74
+ Starts the mod: loads its state, runs `setup`, and adds its hooks, panes, and jobs. `fire`, `lines`, `type`, `callTool`, `press`, `input`, `select`, and `moveTo` start the mod first, so a test calls `start` only before it reads `state` or `shown` with nothing else to run. It rejects with the reason when the mod does not start.
73
75
 
74
76
  ### fire
75
77
 
@@ -80,9 +82,10 @@ fire<N extends RoutedEvent>(event: N, input: Args<N>, below?): Promise<EventResu
80
82
 
81
83
  - `fire('UserPromptSubmit', { prompt: 'hello' })` runs the mod's hooks on a `ModEvent`. `TestInput<E>` is the event's input without the fields `fire` fills: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, and a new `tool_use_id` for each tool event.
82
84
  - For a tool event, `files` is worked out from the call and the test's `files`, as in Claude Code.
83
- - `below` is what the hooks beneath the mod answered. Pass it to test how the mod's answer combines with another, such as a `deny` beneath. When nothing in the mod answers, `fire` returns `below` as it is.
85
+ - `fire('PreToolUse', …)` runs the call's `tool.call`, and its `tool.check` inside it, as Claude Code does. `testMod` runs a mod as if `register.ts` calls `registerPermissionCheck`, so it returns `allow: true` or `ask` with the reason when the mod's hooks or `permissions` job decided the permission. A `PreToolUse` with `agent_id` and `agent_type`, such as `fire('PreToolUse', { tool_name: 'Read', tool_input: { file_path: 'a.ts' }, agent_id: 'agent-7', agent_type: 'explorer' })`, runs as that subagent's call. It returns `updatedInput` when the hooks rewrote the input, `additionalContext` as a list, and `deny` when a hook or Claude Code refused the call. Its `below` is `{ deny }` to test a call Claude Code refuses, and Claude Code runs the call otherwise.
86
+ - On every other event, `below` is what the hooks beneath the mod answered. Pass it to test how the mod's answer combines with another. When nothing in the mod answers, `fire` returns `below` as it is.
84
87
  - Left out, `below` is `{}`, except on `prompt.submit`, where it is what Claude Code answers: the prompt that entered, `{ text, context, origin }`, with the `context` the mod's hooks added.
85
- - It returns the combined answer in Claude Code's own field names: `additionalContext` is a list, a `PreToolUse` decision is `allow: true`, or the key `ask` or `deny` with the reason as its value, and `decision: 'block'` is `block`.
88
+ - It returns the combined answer in Claude Code's own field names: `additionalContext` is a list, and `decision: 'block'` is `block`.
86
89
  - `fire('tool.check', { tool: 'Edit', input: { … }, tool_use_id: 'toolu_1' }, { decision: 'allow' })` asks the mod's permission rules about any call. A name with a dot fires that Claude Code hook-module event as it is.
87
90
 
88
91
  ```ts
@@ -141,7 +144,7 @@ Waits until the work a hook started without awaiting it has run, such as a `void
141
144
 
142
145
  ### lines
143
146
 
144
- - `lines(paneId)` draws an open pane and returns its rows of text. It rejects a pane that is not open: open it first with `tested.type('/<command>')` or `pane.open()` in the mod. The render gets the props `title`, `isFocused: false`, `bodyColumns`, `placement: 'dock'`, `scroll: { offset: 0, bodyRows: 24 }`, and `view: {}`, the main conversation's view.
147
+ - `lines(paneId)` draws an open pane and returns its rows of text. It rejects a pane that is not open: open it first with `tested.type('/<command>')` or `pane.open()` in the mod. The render gets the props `title`, `isFocused`, true once an `open` or `toggle` with `{ focus: true }` opened it, `bodyColumns`, `placement: 'dock'`, `scroll: { offset: 0, bodyRows: 24 }`, and `view: {}`, the main conversation's view.
145
148
  - `lines(slot, props, requestId?)` draws a slot render with `props` and returns its rows. `Default` draws a plain listing of the props it gets, so a test sees what the render changed. Draw `slots.AssistantMessage` with a reply's `text` to test a markdown slot render. Draws that pass one `requestId` grow one streamed reply.
146
149
 
147
150
  ```ts
@@ -169,9 +172,20 @@ test('the band shows the prompt count on its last line', async () => {
169
172
 
170
173
  `callTool(name, input)` runs the mod's permission rules on a call of its own tool, then the tool. `name` is the name given to `tool`, without `mcp__<plugin>__`. It returns `{ result }`, or `{ deny }` when a rule or the input check denies the call. It rejects a call a rule would ask the person about, and a tool the mod does not add.
171
174
 
172
- ### press
175
+ ### press, input, and select
173
176
 
174
- `press(paneId, key)` presses the `Button` of an open pane whose `key` or `label` is `key`, and awaits its `onPress`.
177
+ `press`, `input`, and `select` act on an element of an open pane as the person does, and await its handler. Each passes the handler the event Claude Code passes, with `component: 'Pane'`, `requestId` the pane's id, and `surface: 'terminal'`. Each rejects when the pane does not draw the element: `The pane "<pane>" of <mod> draws no <Element> with the key "<key>".`
178
+
179
+ - `press(paneId, key)` presses the `Button` whose `key` or `label` is `key`, and runs its `onPress`.
180
+ - `press(paneId, key, link)` presses the link `link` in the `Markdown` whose `key` is `key`, and runs its `onLinkPress`.
181
+ - `input(paneId, key, text)` types `text` into the `Input` whose `key` is `key` and presses Enter, which runs its `onSubmit`. With `kind` `'change'` it runs `onInput` instead, as one edit does.
182
+ - `select(paneId, key, value)` picks the option `value` of the `Select` whose `key` is `key`, and runs its `onSelect`. It rejects an option the `Select` does not have.
183
+
184
+ ```ts
185
+ await tested.input('history', 'search', 'deploy')
186
+ await tested.select('history', 'agent', 'explorer')
187
+ expect(await tested.lines('history')).toContain('deploy by explorer')
188
+ ```
175
189
 
176
190
  ### moveTo
177
191
 
@@ -193,8 +207,9 @@ type Fakes = {
193
207
  fs: { read?, write?, list?, exists?, stat? }
194
208
  http: { fetch? }
195
209
  settings: { read? }
196
- ui: { ask?, open? }
197
- agent: { list? }
210
+ ui: { ask?, open?, scroll? }
211
+ session: { messages? }
212
+ agent: { list?, spawn? }
198
213
  clock: { after?, every? }
199
214
  cmod: { call? }
200
215
  }
@@ -206,6 +221,7 @@ Set a fake to answer a call:
206
221
  - A `program` job is the exception: it catches the `process.spawn` error and retries after 1, 2, 4, 8 and 16 seconds on real timers, so its `ready()` waits past `bun test`'s 5-second timeout. Set `fakes.process.spawn` before a test starts a `program` job.
207
222
  - `files` already fills the `fs` fakes: a write is read back, and `list` lists the given files. The fake file system resolves a relative path against `/`, not against `mod.cwd`, so a mod under test reads `${mod.projectRoot}/<file>` rather than a relative path. `fs.list()` with no path rejects.
208
223
  - `ui.open` places every pane by default. A fake that answers `{ isPlaced: false }` holds a pane back.
224
+ - `ui.scroll` answers `{}` by default, as when the window moved. A fake that answers `{ deny }` refuses the scroll.
209
225
  - `clock.after(ms, fn)` and `clock.every(ms, fn)` each return `{ cancel() }`, and so must a fake of either. `clock.after` runs a real timer by default. `clock.every` never fires unless a fake keeps `fn` for the test to call.
210
226
 
211
227
  ```ts