@cmodjs/core 0.1.7 → 0.1.9

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 (87) hide show
  1. package/README.md +2 -2
  2. package/docs/commands.md +14 -14
  3. package/docs/dependencies.md +1 -1
  4. package/docs/hooks.md +69 -13
  5. package/docs/index.md +7 -5
  6. package/docs/install-steps.md +19 -19
  7. package/docs/jobs.md +22 -21
  8. package/docs/mod.md +44 -12
  9. package/docs/state.md +8 -8
  10. package/docs/testing.md +25 -9
  11. package/docs/ui.md +33 -9
  12. package/jobs/check.js +2 -2
  13. package/jobs/context.d.ts.map +1 -1
  14. package/jobs/context.js +2 -0
  15. package/jobs/permissions/decide-permission.js +2 -2
  16. package/jobs/permissions/find-project-scope.d.ts +1 -1
  17. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  18. package/jobs/permissions/find-project-scope.js +9 -9
  19. package/jobs/permissions/match-target.d.ts.map +1 -1
  20. package/jobs/permissions/match-target.js +4 -3
  21. package/mod.d.ts +15 -5
  22. package/mod.d.ts.map +1 -1
  23. package/package.json +2 -1
  24. package/path.d.ts +2 -0
  25. package/path.d.ts.map +1 -0
  26. package/path.js +1 -0
  27. package/records.js +3 -3
  28. package/register.d.ts.map +1 -1
  29. package/register.js +8 -2
  30. package/runtime/claude.d.ts +4 -1
  31. package/runtime/claude.d.ts.map +1 -1
  32. package/runtime/hooks.d.ts +10 -2
  33. package/runtime/hooks.d.ts.map +1 -1
  34. package/runtime/hooks.js +56 -18
  35. package/runtime/lifecycle.d.ts.map +1 -1
  36. package/runtime/lifecycle.js +17 -7
  37. package/runtime/ui.d.ts.map +1 -1
  38. package/runtime/ui.js +38 -8
  39. package/shell.d.ts +2 -0
  40. package/shell.d.ts.map +1 -0
  41. package/shell.js +1 -0
  42. package/src/jobs/check.ts +2 -2
  43. package/src/jobs/context.ts +2 -0
  44. package/src/jobs/permissions/decide-permission.ts +2 -2
  45. package/src/jobs/permissions/find-project-scope.ts +11 -10
  46. package/src/jobs/permissions/match-target.ts +4 -3
  47. package/src/mod.ts +22 -3
  48. package/src/path.ts +1 -0
  49. package/src/records.ts +3 -3
  50. package/src/register.ts +8 -2
  51. package/src/runtime/claude.ts +11 -1
  52. package/src/runtime/hooks.ts +62 -23
  53. package/src/runtime/lifecycle.ts +16 -9
  54. package/src/runtime/ui.ts +35 -8
  55. package/src/shell.ts +1 -0
  56. package/src/testing/fake-claude.ts +14 -5
  57. package/src/testing/fake-elements.ts +5 -7
  58. package/src/testing/fake-files.ts +5 -5
  59. package/src/testing.ts +83 -11
  60. package/src/ui/define-pane.ts +5 -1
  61. package/src/utils/call-effects.ts +12 -11
  62. package/src/utils/parse-shell.ts +28 -36
  63. package/src/utils/paths.ts +4 -19
  64. package/src/vendor.ts +1 -0
  65. package/testing/fake-claude.d.ts +5 -0
  66. package/testing/fake-claude.d.ts.map +1 -1
  67. package/testing/fake-claude.js +13 -3
  68. package/testing/fake-elements.d.ts +5 -8
  69. package/testing/fake-elements.d.ts.map +1 -1
  70. package/testing/fake-elements.js +6 -6
  71. package/testing/fake-files.js +5 -5
  72. package/testing.d.ts +4 -2
  73. package/testing.d.ts.map +1 -1
  74. package/testing.js +65 -9
  75. package/ui/define-pane.d.ts +5 -1
  76. package/ui/define-pane.d.ts.map +1 -1
  77. package/utils/call-effects.d.ts.map +1 -1
  78. package/utils/call-effects.js +12 -11
  79. package/utils/parse-shell.d.ts +5 -9
  80. package/utils/parse-shell.d.ts.map +1 -1
  81. package/utils/parse-shell.js +5 -10
  82. package/utils/paths.d.ts +0 -3
  83. package/utils/paths.d.ts.map +1 -1
  84. package/utils/paths.js +3 -18
  85. package/vendor.d.ts +1 -0
  86. package/vendor.d.ts.map +1 -1
  87. package/vendor.js +209 -12
package/docs/mod.md CHANGED
@@ -17,7 +17,7 @@ type ModDefinition<State, Name> = {
17
17
  }
18
18
  ```
19
19
 
20
- - `name` is the mod's name. Use the `name` in `.claude-plugin/plugin.json`. Claude Mod Manager (CMod) keys the saved state and the `state.json` files by this name, and the data folder and the Skill overrides by the plugin's name, so the two must match. `defineMod` throws `defineMod: the mod needs a name, such as the name in .claude-plugin/plugin.json.` for a blank name.
20
+ - `name` is the mod's name. Use the `name` in `.claude-plugin/plugin.json`. Claude Mod Manager (cmod) keys the saved state and the `state.json` files by this name, and the data folder and the Skill overrides by the plugin's name, so the two must match. `defineMod` throws `defineMod: the mod needs a name, such as the name in .claude-plugin/plugin.json.` for a blank name.
21
21
  - `state` holds the starting values, grouped by how long they last. [state.md](state.md) explains the groups.
22
22
  - `api` holds the methods other mods call. [dependencies.md](dependencies.md) explains it.
23
23
  - `setup` runs once when the mod starts. It adds hooks, panes, slot renders, and jobs. It may be `async`.
@@ -41,9 +41,9 @@ export const greeter = defineMod({
41
41
 
42
42
  ## When setup runs
43
43
 
44
- Claude Code loads the mod at the start of each session. CMod then checks that the mod is set up. A mod with an install step that has not run waits for the person's consent and the install first ([install-steps.md](install-steps.md)). Then CMod loads the saved state, runs `setup`, and marks as open each of the mod's panes that Claude Code already shows.
44
+ Claude Code loads the mod at the start of each session. cmod then checks that the mod is set up. A mod with an install step that has not run waits for the person's consent and the install first ([install-steps.md](install-steps.md)). Then cmod loads the saved state, runs `setup`, and marks as open each of the mod's panes that Claude Code already shows.
45
45
 
46
- The first time a version starts, CMod shows the toast `<name> is ready` and logs `<name> added <what setup added>`, such as `the my-mod pane, a render of AbovePrompt and a hook on UserPromptSubmit`.
46
+ The first time a version starts, cmod shows the toast `<name> is ready` and logs `<name> added <what setup added>`, such as `the my-mod pane, a render of AbovePrompt and a hook on UserPromptSubmit`.
47
47
 
48
48
  When a step fails, the mod does not start. Its progress line above the prompt names the failure and the fix:
49
49
 
@@ -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
  ```
@@ -115,16 +117,18 @@ type Mod<State> = {
115
117
  | --- | --- | --- |
116
118
  | `name` | The mod's name from `defineMod`. | |
117
119
  | `state` | The mod's values. Assign to a key to change it. | [state.md](state.md) |
118
- | `dataFolder` | The mod's own folder in the CMod store: `$XDG_DATA_HOME/cmod/data/<plugin name>`, or `~/.local/share/cmod/data/<plugin name>` when `XDG_DATA_HOME` is not set. The install step gets the same folder as `CMOD_DATA`. CMod deletes it when the mod is removed. | [install-steps.md](install-steps.md) |
120
+ | `dataFolder` | The mod's own folder in the cmod store: `$XDG_DATA_HOME/cmod/data/<plugin name>`, or `~/.local/share/cmod/data/<plugin name>` when `XDG_DATA_HOME` is not set. The install step gets the same folder as `CMOD_DATA`. cmod deletes it when the mod is removed. | [install-steps.md](install-steps.md) |
119
121
  | `projectRoot` | The project the session works in. It follows `/cd`. | |
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
@@ -145,7 +149,7 @@ Both run a program without a shell. `argv[0]` is the program and the rest are it
145
149
  - `spawn` streams `{ stream: 'stdout' | 'stderr', text }` pieces as the program writes them. Its `result` resolves `{ code, signal }` once the stream is read to its end. Its first pull rejects when the program cannot start.
146
150
  - The `for await` loop over a `spawn` is the program's life. Leaving the loop, or calling `return()` on the stream, kills the program, and so does unloading the mod. Returning from the hook does not.
147
151
 
148
- Inside a job, CMod adds the job's deadline to each call ([jobs.md](jobs.md)).
152
+ Inside a job, cmod adds the job's deadline to each call ([jobs.md](jobs.md)).
149
153
 
150
154
  ```ts
151
155
  import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
@@ -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,30 @@ 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
+
211
243
  ## messageOf
212
244
 
213
245
  ```ts
package/docs/state.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # State
2
2
 
3
- `mod.state` holds the mod's values. Claude Mod Manager (CMod) saves them, redraws the mod's panes and slot renders when one changes, and lets a person change the starting values without touching the mod's code.
3
+ `mod.state` holds the mod's values. Claude Mod Manager (cmod) saves them, redraws the mod's panes and slot renders when one changes, and lets a person change the starting values without touching the mod's code.
4
4
 
5
5
  ## Declare the state
6
6
 
@@ -13,7 +13,7 @@
13
13
  | `project` | This project, in every conversation. | Yes |
14
14
  | `global` | Every project. | Yes |
15
15
 
16
- CMod keeps each `session` value for the 20 sessions that wrote it last, and each `project` value for the 20 projects that wrote it last. A session or project past those 20 starts that value over from its starting value.
16
+ cmod keeps each `session` value for the 20 sessions that wrote it last, and each `project` value for the 20 projects that wrote it last. A session or project past those 20 starts that value over from its starting value.
17
17
 
18
18
  Put values the mod works out again on every prompt, such as a git status, in `memory`.
19
19
 
@@ -56,7 +56,7 @@ A group other than `memory`, `session`, `project`, and `global` throws `<mod>: s
56
56
 
57
57
  ## Change a value
58
58
 
59
- Assign to a key. CMod saves the new value and redraws the mod.
59
+ Assign to a key. cmod saves the new value and redraws the mod.
60
60
 
61
61
  - A key the state does not declare throws `<mod>: mod.state.<group>.<key> is not declared. Add it to state.<group> in defineMod with its starting value.`
62
62
  - An object or an array in the state is frozen. Changing it in place throws `<mod>: mod.state.<path> cannot change in place.` Assign a new value instead:
@@ -75,8 +75,8 @@ export const reads = defineMod({
75
75
  })
76
76
  ```
77
77
 
78
- - Keep only JSON data in a saved group. A value CMod cannot save logs `<mod> could not keep state.<group>.<key>: <error>. Keep only JSON data in mod.state.`
79
- - CMod saves values one at a time in the order they were assigned.
78
+ - Keep only JSON data in a saved group. A value cmod cannot save logs `<mod> could not keep state.<group>.<key>: <error>. Keep only JSON data in mod.state.`
79
+ - cmod saves values one at a time in the order they were assigned.
80
80
 
81
81
  ## Where the values come from
82
82
 
@@ -87,7 +87,7 @@ When the mod starts, each value is, in order of precedence:
87
87
  3. The value in the person's own `state.json`.
88
88
  4. The starting value in `defineMod`.
89
89
 
90
- After a `/cd` to another project, CMod loads that project's `project` values and reads its `state.json` again.
90
+ After a `/cd` to another project, cmod loads that project's `project` values and reads its `state.json` again.
91
91
 
92
92
  ## Let a person change the starting values
93
93
 
@@ -111,7 +111,7 @@ A `state.json` in either folder lists only the values to change:
111
111
  - A project `state.json` cannot set `global` values, because one repository cannot change a value for every project.
112
112
  - A file cannot set `memory` values.
113
113
 
114
- CMod ignores each entry it cannot use and logs one line naming the file, the entry, and the fix:
114
+ cmod ignores each entry it cannot use and logs one line naming the file, the entry, and the fix:
115
115
 
116
116
  - a file that is not JSON, or not a JSON object
117
117
  - a group the mod does not declare
@@ -131,7 +131,7 @@ A person replaces the text of a Skill the mod ships by putting a file at the sam
131
131
 
132
132
  - Claude reads that file in place of the mod's from the next time the Skill loads, and updates of the mod keep it.
133
133
  - The project file wins over the person's file.
134
- - CMod drops the file's frontmatter, so the mod's own name and description stay.
134
+ - cmod drops the file's frontmatter, so the mod's own name and description stay.
135
135
  - Only a Skill the mod ships in `skills/<skill>/` is replaced, and only when it loads as `<mod>:<skill>`.
136
136
  - Deleting the file brings the mod's text back.
137
137
 
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
package/docs/ui.md CHANGED
@@ -8,9 +8,10 @@ mod.ui.render<S extends Slot>(slot: S, Component: (props: SlotProps<S>) => Rende
8
8
  mod.ui.toast(text: string): void
9
9
  mod.ui.progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
10
10
  mod.ui.ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
11
+ mod.ui.scroll(args: UiScrollArgs): Promise<UiScrollResult>
11
12
  ```
12
13
 
13
- Claude Mod Manager (CMod) draws the mod again whenever a value in `mod.state` changes. A render reads `mod.state` and returns what to show. It never draws by hand.
14
+ Claude Mod Manager (cmod) draws the mod again whenever a value in `mod.state` changes. A render reads `mod.state` and returns what to show. It never draws by hand.
14
15
 
15
16
  ## Elements
16
17
 
@@ -46,7 +47,7 @@ export function Count({ label, count }: { readonly label: string; readonly count
46
47
 
47
48
  An element called outside a render throws `<Element> was called outside a render. Use it inside a pane's render or a slot's component.` So build elements inside a pane's `render` or a slot's component, never at module load or in a hook.
48
49
 
49
- `drawWith(table, draw, markdown?)` is the function CMod draws a render with. A mod never calls it.
50
+ `drawWith(table, draw, markdown?)` is the function cmod draws a render with. A mod never calls it.
50
51
 
51
52
  ## Panes
52
53
 
@@ -62,14 +63,23 @@ type Pane<State> = {
62
63
  readonly title: string
63
64
  readonly columns?: number | ((state: State) => number | undefined)
64
65
  readonly rows?: number | ((state: State) => number | undefined)
66
+ readonly closeOnEscape?: true
67
+ readonly holdToasts?: true
65
68
  render(mod: Mod<State>, props: Frozen<RenderPropsOf['Pane']>): RenderElement
69
+ onScroll?(mod: Mod<State>, e: Frozen<UiScrollInput>): void | Promise<void>
70
+ onClose?(mod: Mod<State>, e: Frozen<PaneCloseInput>): void | Promise<void>
66
71
  }
67
72
  ```
68
73
 
69
74
  - `id` is 1 to 64 letters, digits, `_`, or `-`. Another id throws `definePane: "<id>" is not a pane id.`
70
75
  - `title` labels the pane's tab while more than one pane is open.
71
- - `columns` and `rows` ask for a size. A number must be a whole number above 0, or `definePane` throws. A function reads the state and returns the size, or `undefined` for Claude Code's default. When the state changes the size, CMod resizes an open pane. A function that returns a bad size keeps an open pane at its size and logs the pane's title to the debug log. `open`, and `toggle` on a closed pane, reject with that size instead.
76
+ - `columns` and `rows` ask for a size. A number must be a whole number above 0, or `definePane` throws. A function reads the state and returns the size, or `undefined` for Claude Code's default. When the state changes the size, cmod resizes an open pane. A function that returns a bad size keeps an open pane at its size and logs the pane's title to the debug log. `open`, and `toggle` on a closed pane, reject with that size instead.
77
+ - `closeOnEscape` closes the pane when the person presses Escape while it holds the keyboard, as their own close does. Left out, Escape gives the keyboard back to the prompt and the pane stays.
78
+ - `holdToasts` holds every toast back while the pane is shown, the toasts of other plugins and of Claude Code included. Use it for a pane the person answers and closes, never for one that stays open.
72
79
  - `render` draws the pane's body. `props` holds `title`, `isFocused`, `bodyColumns`, `placement` (`'dock'` or `'inline'`), `scroll`, the body's window over a taller drawing, and `view`, which transcript is on screen beside the pane.
80
+ - `onScroll` runs after this pane's window moved, by the person's wheel or keys or by `mod.ui.scroll`. `e` holds the new `offset`, the move's `by`, `bodyRows`, `contentRows`, and `origin`. Another pane's scroll and the band's never reach it, and a scroll Claude Code refused does not either.
81
+ - `onClose` runs after the pane closed, by the person or by `close()`. `e.origin.kind` is `'person'` or `'plugin'`.
82
+ - A handler that throws logs `<mod>: the <title> pane's <handler> threw: <error>`.
73
83
 
74
84
  Put each pane in its own file in `src/panes/`.
75
85
 
@@ -98,21 +108,23 @@ export const notesPane = definePane<NotesState>({
98
108
 
99
109
  ```ts
100
110
  type PaneHandle = {
101
- open(): Promise<void>
111
+ open(options?: { readonly focus?: true }): Promise<void>
102
112
  close(): Promise<void>
103
- toggle(): Promise<void>
113
+ toggle(options?: { readonly focus?: true }): Promise<void>
104
114
  readonly isOpen: boolean
105
115
  }
106
116
  ```
107
117
 
108
118
  - `open` asks Claude Code to show the pane. `isOpen` turns true once Claude Code places it. Claude Code may hold a pane back, and then `isOpen` stays false.
109
- - `mod.ui.pane` only adds the pane. CMod opens it only when the mod calls `open` or `toggle`, and Claude Code shows a pane only once the plugin has opened it. So give the person a way in, such as a slash command whose `reply` calls `pane.toggle()`.
119
+ - `{ focus: true }` asks Claude Code to give the pane the keyboard as it opens. Claude Code gives it only while the prompt is empty and holds the keyboard, so typed text, a dialog, or another pane the person is using keeps it. cmod sends `focus` only on that one open, never when it resizes the pane.
120
+ - `mod.ui.pane` only adds the pane. cmod opens it only when the mod calls `open` or `toggle`, and Claude Code shows a pane only once the plugin has opened it. So give the person a way in, such as a slash command whose `reply` calls `pane.toggle()`.
110
121
  - A pane opened from what the person did, such as a slash command they typed or a `Button` they pressed, shows at any terminal width. A pane opened from anything else, such as `setup`, a timer, or `SessionStart`, shows only on a terminal 144 columns wide, or 110 for a pane the person opened before. Below that it waits undrawn until the person opens it or the terminal widens.
111
122
  - `close` closes it. The person closes a pane too, and `isOpen` follows.
112
123
  - `toggle` closes an open pane and opens a closed one.
113
124
  - When the mod starts, `isOpen` is true for each of its panes Claude Code already shows.
114
125
  - Adding two panes with one id throws `<mod>: the pane "<id>" is already added. Give each pane its own id.`
115
- - The line CMod logs when the mod first starts names the pane by its `title`, as in `the Notes pane`.
126
+ - A `render` that throws draws `The <title> pane could not draw: <error>` in the pane, and cmod logs `<mod>: the <title> pane render threw: <error>` once.
127
+ - The line cmod logs when the mod first starts names the pane by its `title`, as in `the Notes pane`.
116
128
 
117
129
  ```tsx
118
130
  import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
@@ -126,7 +138,7 @@ export const notes = defineMod({
126
138
  state: initialState,
127
139
  setup(mod) {
128
140
  const pane = mod.ui.pane(notesPane)
129
- mod.use(slashCommand({ name: 'notes', description: 'Show or hide the notes', reply: () => pane.toggle() }))
141
+ mod.use(slashCommand({ name: 'notes', description: 'Show or hide the notes', reply: () => pane.toggle({ focus: true }) }))
130
142
  },
131
143
  })
132
144
  ```
@@ -157,7 +169,7 @@ export const notes = defineMod({
157
169
  - `Default` draws what Claude Code, and the renders of other mods, would draw. Pass it props to change what it draws, such as `<Default hint="…" />`. Return `<Default />` to change nothing.
158
170
  - A render adds to what it wraps. In `AbovePrompt`, drawing `<Default />` and a line under it keeps the lines of Claude Code and other mods.
159
171
  - A render must draw the same for the same props. One that calls `Default` a different number of times on a second draw falls back to Claude Code's drawing.
160
- - A render that throws falls back to Claude Code's drawing. CMod logs `<mod>: the <Slot> render threw, so Claude Code draws its own: <error>` once.
172
+ - A render that throws falls back to Claude Code's drawing. cmod logs `<mod>: the <Slot> render threw, so Claude Code draws its own: <error>` once.
161
173
  - A mod renders each slot once. A second `mod.ui.render` of one slot throws `<mod>: a render of <Slot> is already added. Render each slot once.` For a markdown slot the error names it as `a render of markdown <Kind>`, such as `a render of markdown CodeBlock`.
162
174
  - `slots.ToolUse` changes a call's own row. A group of calls shows the `slots.ToolGroup` row, which Claude Code builds from the stored message, so a mod that hides a tool's input also sets `isExpanded` on `slots.ToolGroup` to unfold the group into `ToolUse` rows.
163
175
  - No slot covers the permission dialog.
@@ -290,6 +302,18 @@ export const committer = defineMod({
290
302
  })
291
303
  ```
292
304
 
305
+ ## Scrolling
306
+
307
+ ```ts
308
+ mod.ui.scroll(args: { to: 'start' | 'end' | { key: string } | { requestId: string }; in?: string; block?: 'start' | 'center' | 'end' | 'nearest' }): Promise<UiScrollResult>
309
+ ```
310
+
311
+ `scroll` brings something the mod drew into view. `{ key }` is an element the mod drew with that `key`. `'start'` and `'end'` are the top and bottom of the pane whose id `in` names, and `'end'` keeps up with rows the mod adds until the person scrolls. `{ requestId }` is a row Claude Code drew, such as a message of the transcript, which scrolls only while the mod answers the person's own press or key. `block` places the target in the window, `'nearest'` by default. It resolves `{}` once the window moved, or `{ deny }` with the reason it did not.
312
+
313
+ ```tsx
314
+ <Button label="Latest" onPress={() => mod.ui.scroll({ to: 'end', in: 'log' })} />
315
+ ```
316
+
293
317
  ## Test the UI
294
318
 
295
319
  `tested.lines(paneId)` reads an open pane as rows of text. `tested.lines(slot, props)` draws a slot render. `tested.press(paneId, key)` presses a button. `tested.shown.toasts` lists the toasts. [testing.md](testing.md) covers each.
package/jobs/check.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { longestMs } from '../runtime/deadline.js';
2
2
  import { callEffects } from '../utils/call-effects.js';
3
- import { parentOf } from '../utils/paths.js';
3
+ import { dirname } from '../vendor.js';
4
4
  import { listed, messageOf } from '../utils/text.js';
5
5
  import { afterCall, modOf, targetWords } from './context.js';
6
6
  import { matchTarget, targetOf } from './permissions/match-target.js';
@@ -55,6 +55,6 @@ async function triggeredRun(after, run, use, workspace) {
55
55
  existing.push(path);
56
56
  const last = existing.at(-1);
57
57
  if (last !== undefined)
58
- return { command: [...run, ...existing], folder: parentOf(last) };
58
+ return { command: [...run, ...existing], folder: dirname(last) };
59
59
  return commandFolder === undefined ? undefined : { command: [...run], folder: commandFolder };
60
60
  }
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/jobs/context.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,WAAW,CAAA;AAEhD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,wBAAwB,CAAA;AAItE,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAA;AACjE,OAAO,EAAoB,KAAK,SAAS,EAAE,MAAM,qCAAqC,CAAA;AACtF,OAAO,EAAY,KAAK,MAAM,EAAE,MAAM,+BAA+B,CAAA;AAOrE,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,EAAE,UAAU,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,CAOhF;AAaD,wBAAgB,SAAS,CAAC,KAAK,SAAS,MAAM,EAC5C,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,EACtB,YAAY,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,EAChF,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,SAAS,MAAM,EAAE,GAC5C,IAAI,CAUN;AAMD,wBAAgB,KAAK,CAAC,KAAK,SAAS,MAAM,EAAE,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE;IAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,CAEjM;AAED,wBAAgB,SAAS,CAAC,KAAK,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,GAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAyB,GAAG,GAAG,CAAC,KAAK,CAAC,CAwBvL;AAwBD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CASlD"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/jobs/context.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,WAAW,CAAA;AAEhD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,wBAAwB,CAAA;AAItE,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAA;AACjE,OAAO,EAAoB,KAAK,SAAS,EAAE,MAAM,qCAAqC,CAAA;AACtF,OAAO,EAAY,KAAK,MAAM,EAAE,MAAM,+BAA+B,CAAA;AAOrE,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,EAAE,UAAU,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,CAOhF;AAaD,wBAAgB,SAAS,CAAC,KAAK,SAAS,MAAM,EAC5C,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,EACtB,YAAY,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,EAChF,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,SAAS,MAAM,EAAE,GAC5C,IAAI,CAUN;AAMD,wBAAgB,KAAK,CAAC,KAAK,SAAS,MAAM,EAAE,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE;IAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,CAEjM;AAED,wBAAgB,SAAS,CAAC,KAAK,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,GAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAyB,GAAG,GAAG,CAAC,KAAK,CAAC,CA0BvL;AAwBD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CASlD"}
package/jobs/context.js CHANGED
@@ -60,6 +60,8 @@ export function modWithin({ mod, claude }, deadline, workTree = async () => unde
60
60
  read: (path) => within('mod.fs.read', mod.fs.read(path)),
61
61
  write: (path, text) => within('mod.fs.write', mod.fs.write(path, text)),
62
62
  list: (path) => within('mod.fs.list', mod.fs.list(path)),
63
+ exists: (path) => within('mod.fs.exists', mod.fs.exists(path)),
64
+ stat: (path, options) => within('mod.fs.stat', mod.fs.stat(path, options)),
63
65
  },
64
66
  http: { fetch: (url, init) => within('mod.http.fetch', mod.http.fetch(url, init)) },
65
67
  dependencies: dependencyCalls(claude, within),
@@ -2,7 +2,7 @@ import { strictness } from '../../runtime/hooks.js';
2
2
  import { messageOf } from '../../utils/text.js';
3
3
  import { callBaseOf, callEffects } from '../../utils/call-effects.js';
4
4
  import { folderInScope, matchTarget, targetOf } from './match-target.js';
5
- import { resolvePath } from '../../utils/paths.js';
5
+ import { resolve } from '../../vendor.js';
6
6
  const wordBoundary = '\\s;&|()<>\'"`=';
7
7
  export function stricterVerdict(first, second) {
8
8
  return strictness.indexOf(second.decision) > strictness.indexOf(first.decision) ? second : first;
@@ -63,7 +63,7 @@ async function unparsedMatches(target, use, effects, workspace) {
63
63
  const folder = named.length === 0 ? undefined : await folderInScope(shell, workspace);
64
64
  if (folder === undefined)
65
65
  return [];
66
- return named.map((segment) => ({ call: { ...base, path: resolvePath(segment, folder) }, access: undefined, folder }));
66
+ return named.map((segment) => ({ call: { ...base, path: resolve(folder, segment) }, access: undefined, folder }));
67
67
  }
68
68
  function containsWord(line, word) {
69
69
  const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -1,4 +1,4 @@
1
- import { type FileSystem } from '../../utils/paths.js';
1
+ import type { FileSystem } from '../../utils/paths.js';
2
2
  export type ProjectScope = {
3
3
  workTreeOf: (path: string) => Promise<string | undefined>;
4
4
  };
@@ -1 +1 @@
1
- {"version":3,"file":"find-project-scope.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/find-project-scope.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiC,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAErF,MAAM,MAAM,YAAY,GAAG;IAAE,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,CAAA;AAExF,MAAM,MAAM,SAAS,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,YAAY,GAAG,SAAS,CAAA;CAAE,CAAA;AAE3H,wBAAsB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAwB1H"}
1
+ {"version":3,"file":"find-project-scope.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/find-project-scope.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAGtD,MAAM,MAAM,YAAY,GAAG;IAAE,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,CAAA;AAExF,MAAM,MAAM,SAAS,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,YAAY,GAAG,SAAS,CAAA;CAAE,CAAA;AAE3H,wBAAsB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAwB1H"}
@@ -1,9 +1,9 @@
1
- import { nameOf, parentOf, resolvePath } from '../../utils/paths.js';
1
+ import { basename, dirname, resolve } from '../../vendor.js';
2
2
  export async function findProjectScope(pluginRoot, home, fs) {
3
- const skills = parentOf(pluginRoot);
4
- const claude = parentOf(skills);
5
- const root = parentOf(claude);
6
- if (nameOf(skills) !== 'skills' || nameOf(claude) !== '.claude' || root === resolvePath(home, '/'))
3
+ const skills = dirname(pluginRoot);
4
+ const claude = dirname(skills);
5
+ const root = dirname(claude);
6
+ if (basename(skills) !== 'skills' || basename(claude) !== '.claude' || root === resolve('/', home))
7
7
  return undefined;
8
8
  const repositories = new Map();
9
9
  const repositoryOf = async (workTree) => {
@@ -20,7 +20,7 @@ export async function findProjectScope(pluginRoot, home, fs) {
20
20
  return undefined;
21
21
  return {
22
22
  async workTreeOf(path) {
23
- for (let folder = path;; folder = parentOf(folder)) {
23
+ for (let folder = path;; folder = dirname(folder)) {
24
24
  const repository = await repositoryOf(folder);
25
25
  if (repository !== undefined)
26
26
  return repository === project ? folder : undefined;
@@ -31,7 +31,7 @@ export async function findProjectScope(pluginRoot, home, fs) {
31
31
  };
32
32
  }
33
33
  async function readRepository(workTree, fs) {
34
- const gitPath = resolvePath('.git', workTree);
34
+ const gitPath = resolve(workTree, '.git');
35
35
  const git = await fs.stat(gitPath).catch(() => undefined);
36
36
  if (git === undefined)
37
37
  return undefined;
@@ -41,8 +41,8 @@ async function readRepository(workTree, fs) {
41
41
  const gitDirectory = /^gitdir:\s*(.+)$/m.exec(await fs.read(gitPath))?.[1]?.trim();
42
42
  if (gitDirectory === undefined)
43
43
  throw new Error(`${gitPath} has no gitdir line.`);
44
- const linkedDirectory = resolvePath(gitDirectory, workTree);
45
- const commonDirectory = await fs.read(resolvePath('commondir', linkedDirectory)).then((text) => resolvePath(text.trim(), linkedDirectory), () => linkedDirectory);
44
+ const linkedDirectory = resolve(workTree, gitDirectory);
45
+ const commonDirectory = await fs.read(resolve(linkedDirectory, 'commondir')).then((text) => resolve(linkedDirectory, text.trim()), () => linkedDirectory);
46
46
  return (await fs.stat(commonDirectory)).realPath ?? commonDirectory;
47
47
  }
48
48
  catch {
@@ -1 +1 @@
1
- {"version":3,"file":"match-target.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/match-target.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,KAAK,OAAO,EAAE,MAAM,6BAA6B,CAAA;AACxL,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;AAGxD,MAAM,MAAM,WAAW,GAAG;IAAE,OAAO,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAA;AACzI,MAAM,MAAM,SAAS,GAAG,MAAM,WAAW,CAAA;AACzC,MAAM,MAAM,QAAQ,CAAC,GAAG,SAAS,SAAS,IAAI,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,CAAA;AAC9H,MAAM,MAAM,MAAM,GAAG;KAAG,GAAG,IAAI,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC;CAAE,CAAC,SAAS,CAAC,CAAA;AACrE,MAAM,MAAM,WAAW,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAI5F,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAQ/E;AAED,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA6BlI;AA4BD,wBAAsB,aAAa,CAAC,KAAK,EAAE,WAAW,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAI/H"}
1
+ {"version":3,"file":"match-target.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/match-target.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,KAAK,OAAO,EAAE,MAAM,6BAA6B,CAAA;AACxL,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;AAIxD,MAAM,MAAM,WAAW,GAAG;IAAE,OAAO,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAA;AACzI,MAAM,MAAM,SAAS,GAAG,MAAM,WAAW,CAAA;AACzC,MAAM,MAAM,QAAQ,CAAC,GAAG,SAAS,SAAS,IAAI,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,CAAA;AAC9H,MAAM,MAAM,MAAM,GAAG;KAAG,GAAG,IAAI,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC;CAAE,CAAC,SAAS,CAAC,CAAA;AACrE,MAAM,MAAM,WAAW,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAI5F,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAQ/E;AAED,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA6BlI;AA4BD,wBAAsB,aAAa,CAAC,KAAK,EAAE,WAAW,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAI/H"}
@@ -1,7 +1,8 @@
1
1
  import { matchesGlob } from '../../utils/matches-glob.js';
2
2
  import { parseShell } from '../../utils/parse-shell.js';
3
3
  import { callBaseOf } from '../../utils/call-effects.js';
4
- import { nameOf, realPathOf, relativePath } from '../../utils/paths.js';
4
+ import { realPathOf, relativePath } from '../../utils/paths.js';
5
+ import { basename } from '../../vendor.js';
5
6
  const targetKeys = ['command', 'read', 'write', 'fetch', 'subagent', 'tool'];
6
7
  export function targetOf(target) {
7
8
  const keys = targetKeys.filter((key) => key in target);
@@ -51,10 +52,10 @@ function commandPattern(pattern) {
51
52
  const command = parseShell(pattern).commands[0];
52
53
  if (command === undefined)
53
54
  throw new Error(`The command pattern '${pattern}' names no program.`);
54
- return command;
55
+ return command.argv;
55
56
  }
56
57
  function commandMatches(pattern, argv) {
57
- if (nameOf(argv[0]) !== nameOf(pattern[0]))
58
+ if (basename(argv[0]) !== basename(pattern[0]))
58
59
  return false;
59
60
  const wanted = splitArguments(pattern.slice(1));
60
61
  const actual = splitArguments(argv.slice(1));
package/mod.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AskOptions, ClassicHookInputs, CmodDependencies, FsEntry, HookStream, HttpInit, HttpResponse, PermissionRequestDecision, ProcessRunInit, ProcessRunResult, ProcessSpawnChunk, ProcessSpawnRequest, ProcessSpawnResult, RenderElement, Settings, SettingsReadArgs } from 'claude-code';
1
+ import type { AgentSpawnArgs, AgentSpawnResult, AskOptions, ClassicHookInputs, CmodDependencies, FsEntry, FsStat, FsStatOptions, HookStream, HttpInit, HttpResponse, PaneOpenArgs, PermissionRequestDecision, ProcessRunInit, ProcessRunResult, ProcessSpawnChunk, ProcessSpawnRequest, ProcessSpawnResult, RenderElement, Settings, SettingsReadArgs, UiScrollArgs, UiScrollResult } from 'claude-code';
2
2
  import type { Claude } from './runtime/claude.js';
3
3
  import type { RoutedEvent } from './runtime/hooks.js';
4
4
  import type { RoutedHook } from './runtime/router.js';
@@ -11,16 +11,16 @@ export type ProgressStep = {
11
11
  readonly label?: string;
12
12
  };
13
13
  export type PaneHandle = {
14
- open(): Promise<void>;
14
+ open(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>;
15
15
  close(): Promise<void>;
16
- toggle(): Promise<void>;
16
+ toggle(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>;
17
17
  readonly isOpen: boolean;
18
18
  };
19
19
  export type { Claude, RoutedEvent, RoutedHook, ToolCalls };
20
20
  export { longestMs } from './runtime/deadline.js';
21
21
  export { notInstalled } from './runtime/dependencies.js';
22
22
  export { messageOf } from './utils/text.js';
23
- export type ModEvent = 'SessionStart' | 'SessionEnd' | 'UserPromptSubmit' | 'InstructionsLoaded' | 'PreToolUse' | 'PermissionRequest' | 'PermissionDenied' | 'PostToolUse' | 'PostToolUseFailure' | 'PostToolBatch' | 'SubagentStart' | 'SubagentStop' | 'Notification' | 'PreCompact' | 'Stop' | 'StopFailure' | 'CwdChanged';
23
+ export type ModEvent = 'SessionStart' | 'SessionEnd' | 'UserPromptSubmit' | 'InstructionsLoaded' | 'PreToolUse' | 'PermissionRequest' | 'PermissionDenied' | 'PostToolUse' | 'PostToolUseFailure' | 'PostToolBatch' | 'SubagentStart' | 'SubagentStop' | 'Notification' | 'PreCompact' | 'Stop' | 'StopFailure' | 'CwdChanged' | 'FileChanged';
24
24
  export type HookAnswer = {
25
25
  continue?: boolean;
26
26
  stopReason?: string;
@@ -69,7 +69,8 @@ type HookInputs = Omit<ClassicHookInputs, 'PreToolUse' | 'PostToolUse' | 'PostTo
69
69
  };
70
70
  CwdChanged: Omit<ClassicHookInputs['CwdChanged'], 'transcript_path'>;
71
71
  };
72
- export type ModHook<E extends ModEvent> = (input: HookInputs[E]) => HookAnswer | void | Promise<HookAnswer | void>;
72
+ export type HookInput<E extends ModEvent> = HookInputs[E];
73
+ export type ModHook<E extends ModEvent> = (input: HookInput<E>) => HookAnswer | void | Promise<HookAnswer | void>;
73
74
  export type Mod<State extends object = Record<never, never>> = {
74
75
  readonly name: string;
75
76
  readonly state: Readonly<State>;
@@ -82,6 +83,7 @@ export type Mod<State extends object = Record<never, never>> = {
82
83
  toast(text: string): void;
83
84
  progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>;
84
85
  ask(question: string, options?: readonly string[] | AskOptions): Promise<string>;
86
+ scroll(args: UiScrollArgs): Promise<UiScrollResult>;
85
87
  };
86
88
  readonly process: {
87
89
  run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>;
@@ -91,6 +93,8 @@ export type Mod<State extends object = Record<never, never>> = {
91
93
  read(path: string): Promise<string>;
92
94
  write(path: string, text: string): Promise<void>;
93
95
  list(path?: string): Promise<FsEntry[]>;
96
+ exists(path: string): Promise<boolean>;
97
+ stat(path: string, options?: FsStatOptions): Promise<FsStat>;
94
98
  };
95
99
  readonly http: {
96
100
  fetch(url: string, init?: HttpInit): Promise<HttpResponse>;
@@ -98,6 +102,12 @@ export type Mod<State extends object = Record<never, never>> = {
98
102
  readonly settings: {
99
103
  read(args?: SettingsReadArgs): Promise<Settings>;
100
104
  };
105
+ readonly session: {
106
+ messages: Claude['session']['messages'];
107
+ };
108
+ readonly agent: {
109
+ spawn(args: AgentSpawnArgs): Promise<AgentSpawnResult>;
110
+ };
101
111
  readonly projectRoot: string;
102
112
  readonly cwd: string;
103
113
  readonly dependencies: CmodDependencies;