@cmodjs/core 0.1.10 → 0.1.12

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 (64) hide show
  1. package/docs/commands.md +16 -13
  2. package/docs/dependencies.md +8 -0
  3. package/docs/hooks.md +10 -1
  4. package/docs/index.md +4 -3
  5. package/docs/install-steps.md +19 -3
  6. package/docs/jobs.md +23 -7
  7. package/docs/mod.md +58 -3
  8. package/docs/testing.md +27 -3
  9. package/jobs/context.d.ts +2 -2
  10. package/jobs/context.d.ts.map +1 -1
  11. package/jobs/context.js +2 -2
  12. package/jobs/permissions/decide-permission.d.ts +2 -2
  13. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  14. package/jobs/permissions/match-target.d.ts +3 -2
  15. package/jobs/permissions/match-target.d.ts.map +1 -1
  16. package/jobs/permissions/match-target.js +24 -15
  17. package/jobs/permissions.d.ts +1 -1
  18. package/jobs/permissions.d.ts.map +1 -1
  19. package/jobs/permissions.js +18 -6
  20. package/jobs/prompt.d.ts.map +1 -1
  21. package/jobs/prompt.js +34 -3
  22. package/mod.d.ts +11 -2
  23. package/mod.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/records.d.ts +4 -0
  26. package/records.d.ts.map +1 -1
  27. package/records.js +27 -6
  28. package/register.d.ts.map +1 -1
  29. package/register.js +3 -0
  30. package/runtime/claude.d.ts +7 -0
  31. package/runtime/claude.d.ts.map +1 -1
  32. package/runtime/hooks.js +4 -4
  33. package/runtime/lifecycle.d.ts +1 -0
  34. package/runtime/lifecycle.d.ts.map +1 -1
  35. package/runtime/lifecycle.js +80 -10
  36. package/runtime/state.d.ts +2 -1
  37. package/runtime/state.d.ts.map +1 -1
  38. package/runtime/state.js +12 -1
  39. package/src/jobs/check.ts +2 -2
  40. package/src/jobs/context.ts +5 -5
  41. package/src/jobs/permissions/decide-permission.ts +4 -4
  42. package/src/jobs/permissions/match-target.ts +24 -16
  43. package/src/jobs/permissions.ts +18 -7
  44. package/src/jobs/prompt.ts +31 -3
  45. package/src/mod.ts +15 -1
  46. package/src/records.ts +28 -8
  47. package/src/register.ts +3 -0
  48. package/src/runtime/claude.ts +3 -0
  49. package/src/runtime/hooks.ts +5 -5
  50. package/src/runtime/lifecycle.ts +85 -10
  51. package/src/runtime/state.ts +13 -1
  52. package/src/testing/fake-claude.ts +19 -3
  53. package/src/testing/fake-elements.ts +6 -1
  54. package/src/testing.ts +1 -0
  55. package/src/utils/call-effects.ts +13 -11
  56. package/testing/fake-claude.d.ts +9 -0
  57. package/testing/fake-claude.d.ts.map +1 -1
  58. package/testing/fake-claude.js +13 -2
  59. package/testing/fake-elements.js +6 -1
  60. package/testing.d.ts.map +1 -1
  61. package/testing.js +1 -0
  62. package/utils/call-effects.d.ts +4 -8
  63. package/utils/call-effects.d.ts.map +1 -1
  64. package/utils/call-effects.js +6 -3
package/docs/commands.md CHANGED
@@ -50,29 +50,32 @@ Runs every check this machine can run on the mod at `path` (default: the current
50
50
  3. Checks that each step runs a script.
51
51
  4. Checks its imports.
52
52
  5. Looks for prebuilt binaries outside `cli/`.
53
- 6. Validates it with Claude Code, `claude plugin validate --strict`.
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
- 8. Lints it with `oxlint`.
56
- 9. Runs, with `bun test`, every test file that does not import `claude-code/testing`.
57
- 10. Runs, with `claude plugin test`, the test files that import `claude-code/testing`. It skips this when none does.
58
- 11. Checks its name against other plugins. Only a project plugin, in `.claude/skills/<name>/` of a repository, runs this check: it fails when an installed or linked plugin has the same name and hides the project plugin. Any other mod passes it at once.
53
+ 6. Bundles the hooks module as `cmod publish` does, and names each reason a bundle fails.
54
+ 7. Validates it with Claude Code, `claude plugin validate --strict`.
55
+ 8. Type-checks it with `tsc`, with `tsconfig.json`, and then with `tests/tsconfig.json` when it exists. `cmod new` writes both: the root config leaves Bun out of `src/`, which runs inside Claude Code, and the tests config adds `bun-types`, so a test can run a real program with `Bun.spawn` or read files with `node:fs`. 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.
56
+ 9. Lints it with `oxlint`.
57
+ 10. Runs, with `bun test`, every test file that does not import `claude-code/testing`.
58
+ 11. Runs, with `claude plugin test`, the test files that import `claude-code/testing`. It skips this when none does.
59
+ 12. Checks its name against other plugins. Only a project plugin, in `.claude/skills/<name>/` of a repository, runs this check: it fails when an installed or linked plugin has the same name and hides the project plugin. Any other mod passes it at once.
59
60
 
60
61
  `cmod check` fetches `tsc` 7.0.2 and `oxlint` 1.86.0 into the cmod store the first time. It ends with a count of passed, failed, and skipped checks, and exits 1 when one failed.
61
62
 
62
63
  ### cmod try
63
64
 
64
65
  ```text
65
- cmod try <owner/repo | path> [--yes] [-- claude arguments]
66
+ cmod try <owner/repo | path>... [--yes] [--home <folder>] [-- claude arguments]
66
67
  ```
67
68
 
68
- Starts one throwaway Claude Code session with a mod set up. Installs the mod's packages, installs the cmod plugin when Claude Code lacks it, and runs the mod's install step after asking consent. Then starts one Claude Code session with the mod loaded through `--plugin-dir`.
69
+ Starts one throwaway Claude Code session with mods set up, for trying a mod by hand or testing it with an agent. Installs each mod's packages, installs the cmod plugin when Claude Code lacks it, and runs each mod's install step after asking consent. Then starts one Claude Code session with every mod loaded through `--plugin-dir`.
69
70
 
71
+ - The session runs with a new, empty home folder, so it sees only the mods named here and changes none of your Claude Code settings, mods, key bindings, or history. The folder is deleted when the session ends.
72
+ - The session keeps `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY`. Set one of them to skip the login. `claude setup-token` prints a token for `CLAUDE_CODE_OAUTH_TOKEN`.
73
+ - `--home <folder>` runs in that folder and keeps it, so the next `cmod try --home <folder>` starts from it. `--home ~` runs in your own home, with your settings and mods.
70
74
  - Unlike `cmod link`, it builds no program from `cli/`. A mod whose `package.json` `cmod.program` names a program downloads it from the GitHub release of the mod's version, so it fails until `cmod publish` has released that version.
71
-
72
- - When the session ends, even when its terminal closes, it runs the mod's uninstall step and deletes its record, data folder, approval, and program, so the mod stays uninstalled. A checkout already set up from the same folder keeps its setup.
75
+ - When the session ends, even when its terminal closes, it runs each mod's uninstall step and deletes its record, data folder, approval, key bindings, and program, so the mod stays uninstalled. A checkout already set up from the same folder keeps its setup.
73
76
  - A GitHub mod is cloned into a temporary folder, which is deleted too.
74
- - A mod another folder has set up is refused, so `cmod try` never replaces an installed copy.
75
- - Arguments after `--` go to `claude`, such as `-- -p "hello"`.
77
+ - A mod another folder has set up in that home is refused, so `cmod try` never replaces an installed copy.
78
+ - Arguments after `--` go to `claude`, such as `-- -p "hello"`. An agent tests a mod with one `cmod try <path> --yes -- -p "<prompt>"` run.
76
79
 
77
80
  ### cmod publish
78
81
 
@@ -82,7 +85,7 @@ cmod publish [path] [--dry-run]
82
85
 
83
86
  Releases the mod at `path` (default: the current folder) at the version in its `plugin.json`:
84
87
 
85
- 1. Builds the release from the committed files, leaving out `cli/`, `.github/`, and `.claude/`. A `"files"` list in `package.json` limits the release to the paths it lists, plus the folders of the install and uninstall steps, `.claude-plugin/`, `package.json`, the README, and the license, the way `npm publish` reads it. List every folder the hooks module imports from, such as `"files": ["hooks", "src", "skills"]`, because the bundle in step 2 is built from the release.
88
+ 1. Builds the release from the committed files, leaving out `cli/`, `.github/`, and `.claude/`. A `"files"` list in `package.json` limits the release to the paths it lists, plus the folders of the install and uninstall steps, `.claude-plugin/`, `package.json`, `tsconfig.json`, the README, and the license, the way `npm publish` reads it. `tsconfig.json` holds the JSX settings the bundle in step 2 needs. List every folder the hooks module imports from, such as `"files": ["hooks", "src", "skills"]`, because the bundle in step 2 is built from the release.
86
89
  2. Bundles the hooks module that `hooks/hooks.json` names, with the mod's source and packages, into one readable `.js` file, writes each control character in it as a `\u` escape, and points `hooks/hooks.json` at it. Anthropic's plugin directory reads a repository without installing its packages, so it can follow a mod only when all its code is in one file.
87
90
  3. Checks the release with `claude plugin validate --strict`, and stops with the validator's message when it fails.
88
91
  4. Commits the release as the `release` branch, and builds the release archive from that commit.
@@ -100,3 +100,11 @@ notInstalled(name: string): string
100
100
  ## Test the calls
101
101
 
102
102
  `testMod(outline, { dependencies: { tracer: { signatures: async () => [] } } })` answers tracer's methods with the functions given. A call to a mod the test does not fake fails with `tracer is not installed. Run cmod install tracer.` [testing.md](testing.md) covers it.
103
+
104
+ A test of the provider's own `api` fires `cmod.call`, as a caller's `mod.dependencies` does. The answer is `{ value }` with what the method returned, or `{ deny }` with the reason it failed:
105
+
106
+ ```ts
107
+ const tested = testMod(tracer, { files: { '/work/a.ts': 'export function greet() {}\n' } })
108
+
109
+ expect(await tested.fire('cmod.call', { to: 'tracer', method: 'signatures', input: { path: '/work/a.ts' } })).toEqual({ value: [{ name: 'greet', line: 1 }] })
110
+ ```
package/docs/hooks.md CHANGED
@@ -5,13 +5,22 @@
5
5
  ## mod.on
6
6
 
7
7
  ```ts
8
- mod.on<E extends ModEvent>(event: E, hook: ModHook<E>): void
8
+ mod.on<E extends ModEvent>(event: E, hook: ModHook<E>, options?: { timeoutMs?: number }): void
9
9
 
10
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 that waits on something slow, such as a model call or a server, takes `timeoutMs`. Past it, the event goes on as if the hook were absent, and cmod logs `<mod>: the <event> hook passed its <n> s timeout`. Claude Code also gives a hook's own code 10 seconds, and the time a hook waits on a `mod` call does not count toward those 10 seconds, so `timeoutMs` is the limit on a slow call. `timeoutMs` is a whole number above 0 and at most `2147483647`, and any other value stops `setup` with the fix.
16
+
17
+ ```ts
18
+ mod.on('Stop', async ({ last_assistant_message }) => {
19
+ const verdict = await mod.model.complete({ model: 'haiku', prompt: `Is this reply complete? Answer yes or no.\n\n${last_assistant_message}` })
20
+ if (verdict.isAnswered && verdict.text.trim().toLowerCase().startsWith('no')) return { decision: 'block', reason: 'Finish the reply.' }
21
+ }, { timeoutMs: 8000 })
22
+ ```
23
+
15
24
  `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
25
 
17
26
  ```ts
package/docs/index.md CHANGED
@@ -55,14 +55,15 @@ A program the mod needs lives as source in `cli/`. `cmod publish` builds it and
55
55
  | Question | File |
56
56
  | --- | --- |
57
57
  | How do I define a mod, and what can `mod` call? | [mod.md](mod.md) |
58
+ | How does a mod watch something outside Claude Code, tell Claude about it, or ask a model? | [mod.md](mod.md#mod-every) |
58
59
  | How do I keep values across prompts, sessions, and projects? How does a person change them? | [state.md](state.md) |
59
60
  | How do I react to a Claude Code event, and what can a hook answer? | [hooks.md](hooks.md) |
60
61
  | How do I draw a pane, change a row Claude Code draws, or ask the person something? | [ui.md](ui.md) |
61
62
  | How do I add a slash command, a tool, a permission rule, a check, a prompt, a status line, or a background program? | [jobs.md](jobs.md) |
62
63
  | How does one mod call another? | [dependencies.md](dependencies.md) |
63
- | How does a mod change the machine, ship a program, or clean up after itself? | [install-steps.md](install-steps.md) |
64
+ | How does a mod change the machine, bind a key, ship a program, or clean up after itself? | [install-steps.md](install-steps.md) |
64
65
  | How do I test a mod without Claude Code? | [testing.md](testing.md) |
65
- | Which `cmod` command links, checks, tries, or publishes a mod? | [commands.md](commands.md) |
66
+ | Which `cmod` command links, checks, tries in a clean Claude Code, or publishes a mod? | [commands.md](commands.md) |
66
67
 
67
68
  ## The Public API
68
69
 
@@ -72,7 +73,7 @@ A mod imports from the files below, and every other file in this package is inte
72
73
  | --- | --- | --- |
73
74
  | `mod.js` | `defineMod`, `ModDefinition`, `Mod`, `JobContext`, `Job`, `ProgressStep`, `PaneHandle`, `longestMs`, `messageOf` | [mod.md](mod.md), [jobs.md](jobs.md), [ui.md](ui.md) |
74
75
  | `mod.js` | `Claude`, `RoutedEvent`, `RoutedHook`, `ToolCalls` | [jobs.md](jobs.md) |
75
- | `mod.js` | `ModEvent`, `ModHook`, `HookInput`, `HookAnswer` | [hooks.md](hooks.md) |
76
+ | `mod.js` | `ModEvent`, `ModHook`, `HookOptions`, `HookInput`, `HookAnswer` | [hooks.md](hooks.md) |
76
77
  | `mod.js` | `notInstalled` | [dependencies.md](dependencies.md) |
77
78
  | `register.js` | `registerMod`, `registerPermissionCheck` | [mod.md](mod.md) |
78
79
  | `shell.js` | `parseShell`, `ParsedShell`, `ShellCommand` | [hooks.md](hooks.md) |
@@ -9,7 +9,8 @@ A mod that changes the machine, such as by adding a shell alias or downloading a
9
9
  "cmod": {
10
10
  "install": "./setup/install.sh",
11
11
  "uninstall": "./setup/uninstall.sh",
12
- "program": "hello"
12
+ "program": "hello",
13
+ "keys": { "shift+tab": "/mode" }
13
14
  }
14
15
  }
15
16
  ```
@@ -19,6 +20,7 @@ Each key is optional.
19
20
  - `install` is one shell command that sets the mod up.
20
21
  - `uninstall` is one shell command that undoes it.
21
22
  - `program` is the name of a program cmod downloads from the mod's GitHub release into `~/.local/bin`. [Ship a program](#ship-a-program) explains it.
23
+ - `keys` binds keys in Claude Code to the mod's slash commands. [Key bindings](#key-bindings) explains it.
22
24
 
23
25
  cmod refuses a `cmod` key that breaks these rules, with the fix in the message:
24
26
 
@@ -27,19 +29,33 @@ cmod refuses a `cmod` key that breaks these rules, with the fix in the message:
27
29
  - Each names at least one script file inside the mod, and no script at the mod's root. cmod asks consent for the whole folder of each script, so put the scripts in a folder, such as `setup/`.
28
30
  - A symbolic link in a script folder points at a file.
29
31
  - `program` is a command name: letters, digits, `.`, `_`, and `-`, starting with a letter or digit.
32
+ - `keys` binds at least one key, and each binds one of the mod's commands as `/<name>`.
30
33
  - `.claude-plugin/plugin.json` has a `version`.
31
34
 
32
35
  ## Consent
33
36
 
34
- Before a step runs for the first time, cmod shows the person the commands and asks to run them. cmod hashes the commands and every file in each script folder, and remembers the hash the person approved. A change to any of those files asks again.
37
+ Before a step runs for the first time, cmod shows the person the commands and the keys, and asks to run them. cmod hashes the commands, the keys, and every file in each script folder, and remembers the hash the person approved. A change to any of those asks again.
35
38
 
36
- - In Claude Code, the mod asks `<mod> runs <install> to install, and <uninstall> when you remove it. Run it now?` with `Install` and `Not now`. A progress line above the prompt shows the install.
39
+ - In Claude Code, the mod asks `<mod> runs <install> to install, runs <uninstall> when you remove it and binds shift+tab to /mode. Install it now?` with `Install` and `Not now`, naming only what the mod has. A progress line above the prompt shows the install.
37
40
  - In a session with no screen, such as `claude -p`, the mod waits and logs `<mod> waits for consent to run <install>. Run cmod install <mod> in a terminal.`
38
41
  - In a terminal, `cmod install`, `cmod link`, `cmod try`, and `cmod update` print the commands and ask `Run them? [y/N]`. `--yes` approves without asking.
39
42
  - `Not now` leaves the mod off and logs `<mod> is not installed. Run cmod install <mod> to install it.`
40
43
 
41
44
  The mod starts only once its install step has run ([mod.md](mod.md)).
42
45
 
46
+ ## Key bindings
47
+
48
+ A plugin cannot bind a key in Claude Code: Claude Code reads key bindings only from the person's `keybindings.json`. So cmod writes the mod's `keys` there, with consent, as the setup's last step:
49
+
50
+ ```json
51
+ { "bindings": [{ "context": "Chat", "bindings": { "shift+tab": "command:mode" } }] }
52
+ ```
53
+
54
+ - cmod writes each key into the `Chat` bindings of `~/.claude/keybindings.json`, or of `$CLAUDE_CONFIG_DIR/keybindings.json` when that variable is set. When the file is a symbolic link, cmod writes the file it points to. Claude Code reads the change at once, with no restart.
55
+ - A key the person already bound to something else stays theirs. cmod logs `~/.claude/keybindings.json binds <key> to "<action>", so it stays. To use /<name> on <key>, put "<key>": "command:<name>" in its Chat bindings.`
56
+ - An upgrade that drops a key removes its binding. Removing the mod removes each binding it added that still runs its command, and a `Chat` block that leaves empty, and leaves a binding the person changed.
57
+ - A key's command takes `immediate: true` and a `reply` that returns `undefined`, so it works while Claude works and adds no row to the conversation ([jobs.md](jobs.md#slashcommand)). A key bound to `shift+tab` replaces Claude Code's own mode switch, and Claude Code then drops its `shift+tab` hint.
58
+
43
59
  ## How a step runs
44
60
 
45
61
  cmod runs a step with `sh -c "<command>"` in the mod's folder. So `./setup/install.sh` runs the script itself, and the script must be executable: `chmod +x setup/*.sh`.
package/docs/jobs.md CHANGED
@@ -18,7 +18,7 @@ A job's bad options throw at one of two times. `check`, `permissions`, and `prom
18
18
  | `tool` | `jobs/tool.js` | A tool Claude calls |
19
19
  | `permissions` | `jobs/permissions.js` | Rules that deny a call, or ask the person first |
20
20
  | `check` | `jobs/check.js` | A command that runs after matching calls, and reports a failure to Claude |
21
- | `prompt` | `jobs/prompt.js` | Text Claude reads: once, before matching prompts, or after matching calls |
21
+ | `prompt` | `jobs/prompt.js` | Text Claude reads: kept current with the mod's state, before matching prompts, or after matching calls |
22
22
  | `statusLine` | `jobs/status-line.js` | A status line |
23
23
  | `program` | `jobs/program.js` | A background program the mod talks to over HTTP |
24
24
 
@@ -57,6 +57,12 @@ type Reply = string | { text?: string; context?: string } | undefined
57
57
  - `description` is the line the typeahead and `/help` show.
58
58
  - `argumentHint` is drawn dim after the name, such as `[path]`.
59
59
  - `immediate: true` runs the command at once while Claude is still working. Without it, the command waits for the turn to end.
60
+ - A command lasts for the session. Adding a command with a name the session already has replaces it, and Claude Code has no call that removes one, so a command whose source is gone answers that it no longer applies.
61
+ - A key the mod binds in its `package.json` `"cmod": { "keys": { "shift+tab": "/mode" } }` runs the command ([install-steps.md](install-steps.md#key-bindings)). The command of a key takes `immediate: true`, so the key works while Claude works, and a `reply` that returns `undefined`, so a press adds no row to the conversation. It changes `mod.state`, and a pane or band that shows the state is how the person sees the press. cmod logs `<mod>: <key> runs /<name>, which waits for Claude's turn to end and adds a row to the conversation. Give /<name> immediate: true.` for a key whose command lacks it.
62
+
63
+ ```ts
64
+ mod.use(slashCommand({ name: 'mode', description: 'Switch to the next mode', immediate: true, reply: (_input, mod) => void (mod.state.session.mode = next(mod.state.session.mode)) }))
65
+ ```
60
66
  - `reply` gets `args` and `positionals`. `args` is everything after the name as Claude Code passes it, and `''` for a bare `/<name>`. cmod hands it on unchanged. `positionals` is that text split into words the way a shell splits them, so extra spaces never reach it. `tested.type('/todo milk')` drops the spaces after the name.
61
67
  - A `string` reply, or `text`, shows as the command's output. `context` is text Claude reads after the output, and the person never sees. `undefined` shows nothing.
62
68
  - A `reply` that throws shows `/<name> failed: <error>`.
@@ -142,6 +148,7 @@ export const tickets = defineMod({
142
148
  | `{ tool: 'mcp__tickets__*' }` | A call of a matching tool name |
143
149
 
144
150
  - A `read`, `write`, `fetch`, `subagent`, or `tool` pattern is a glob, matched without regard to case.
151
+ - A pattern that starts with `!` leaves out the calls it matches. A call matches the target when it matches a pattern without `!` and no pattern with one: `{ write: ['**', '!docs/plans/**'] }` matches every write except those under `docs/plans/`, and `{ command: ['git', '!git status', '!git log'] }` matches every git command except those two. A line of several commands matches when one of its commands does, so `git status && git push` matches that rule. A target needs a pattern without `!`, or it throws `A <key> target needs a pattern without "!" for its "!" patterns to leave calls out of, such as ['**', ...]`.
145
152
  - A `read` or `write` pattern is relative to the project root. A pattern that starts with `~/` is relative to the home folder, and one that starts with `/` is absolute. A pattern that starts with `**` matches an absolute path too. A path that leads through a symbolic link also matches by where it lands.
146
153
  - A mod installed for the person matches calls in every folder. A project plugin, made with `cmod new --project`, matches only calls inside its own repository's work trees, linked worktrees included, and its relative patterns start at the root of the work tree the call is in.
147
154
 
@@ -160,7 +167,9 @@ A `command` pattern is not a glob. cmod splits it into words the way it splits a
160
167
  ## permissions
161
168
 
162
169
  ```ts
163
- permissions<State>(rules: { deny?: Rule<State>[]; ask?: Rule<State>[] }): Job<void, State>
170
+ permissions<State>(rules: PermissionRules<State> | ((state: State) => PermissionRules<State>)): Job<void, State>
171
+
172
+ type PermissionRules<State> = { deny?: Rule<State>[]; ask?: Rule<State>[] }
164
173
 
165
174
  type Rule<State> = Target & {
166
175
  when?: (call: <the call of the target's key>, mod: Mod<State>) => boolean | Promise<boolean>
@@ -169,23 +178,24 @@ type Rule<State> = Target & {
169
178
  ```
170
179
 
171
180
  - Before each call, cmod tries every `deny` rule, then every `ask` rule. The first rule that matches decides, and its `reason` goes with the decision. No match leaves the call to Claude Code.
181
+ - Rules that change with the mod's state come from a function of `mod.state`, which cmod calls before each call. A mode that changes what Claude may write changes `mod.state`, and the next call follows it, as the panes and a `prompt` with no trigger do. A function that throws, or returns a rule that names no target, denies the call with `The permission rules of <mod> failed, so it denies the call: <error>`. A function that returns `{}` leaves every call to Claude Code.
172
182
  - The strictest answer wins: a mod's `ask` or `deny` beats Claude Code's `allow`, and Claude Code's `deny` beats a mod's `ask`.
173
183
  - `when` narrows a rule. The rule matches only when `when` resolves true. A `when` that throws makes the rule match, and the reason adds `Its when check failed: <error>`.
174
184
  - When cmod cannot read the call or the session, it denies the call with the reason.
175
- - For a PowerShell command, or a Bash command cmod cannot fully parse, a `command` rule matches when every word of its pattern appears in the command line. A `read` or `write` rule matches when the command line holds the last segment of its pattern, after the last `/`, and only when that segment holds none of `*?[]{}`. So `{ write: 'Domain.md' }` matches such a command that names `Domain.md`, and `{ write: '**/.env*' }` never matches one.
185
+ - For a PowerShell command, or a Bash command cmod cannot fully parse, a `command` rule matches when every word of its pattern appears in the command line. A `read` or `write` rule matches when the command line holds the last segment of its pattern, after the last `/`, and only when that segment holds none of `*?[]{}`. So `{ write: 'Domain.md' }` matches such a command that names `Domain.md`, and `{ write: '**/.env*' }` never matches one. A `!` pattern leaves nothing out of such a line, so the rule errs toward matching.
176
186
  - `permissions({})` throws `permissions: give it a deny or an ask rule, or remove it from setup.`
177
187
  - A mod that uses `permissions` calls `registerPermissionCheck(addHook)` after `registerMod` in `hooks/register.ts` ([mod.md](mod.md)). Without it the mod does not start.
178
188
 
179
- `when` gets the call, typed by the target's key. `jobs/permissions.js` exports each type:
189
+ `when` gets the call, typed by the target's key. `jobs/permissions.js` exports each type, and `ToolCall` is any of them:
180
190
 
181
- | Key | Call type | Fields beyond `tool`, `agentId`, and `agentType` |
191
+ | Key | Call type | Fields beyond `tool`, `input`, `agentId`, and `agentType` |
182
192
  | --- | --- | --- |
183
193
  | `command` | `CommandCall` | `commands`, each `[program, ...arguments]`, and `isFullyParsed` |
184
194
  | `read`, `write` | `FileCall` | `path`, and for an `Edit` or a `Write`, `content`, the new text, and `previousContent`, the text before |
185
195
  | `fetch` | `FetchCall` | `url` |
186
196
  | `subagent`, `tool` | `ToolCall` | none |
187
197
 
188
- `agentId` and `agentType` name the subagent that makes the call. In the main conversation `agentId` is absent, and `agentType` is the agent the session started as, when it started as one. `jobs/permissions.js` also exports `Target`, `Rule`, and `PermissionRules`.
198
+ `tool` is the tool's name, such as `Bash`, and `input` is its input as Claude Code sent it, such as `{ command, description }` for `Bash`, so a rule reads any field cmod does not parse. `agentId` and `agentType` name the subagent that makes the call. In the main conversation `agentId` is absent, and `agentType` is the agent the session started as, when it started as one. `jobs/permissions.js` also exports `Target`, `Rule`, and `PermissionRules`.
189
199
 
190
200
  ```ts
191
201
  import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
@@ -249,11 +259,12 @@ prompt<State>(options: {
249
259
 
250
260
  | Options | When Claude reads the text |
251
261
  | --- | --- |
252
- | neither | Once per conversation, as a block of context named `name`. A block of that name already in the context wins. |
262
+ | neither | At the start of each conversation, as a block of context named `name`. A block of that name already in the context wins. Then again whenever `mod.state` changes and the text differs from what Claude last got. |
253
263
  | `when` | With each prompt the person sends whose text matches the `RegExp`, or for which the function returns true. `input.userPrompt` is the prompt. |
254
264
  | `after` | After each call that matches a target and succeeds, with the call's result. `input.call` is the matched call. |
255
265
 
256
266
  - With `when` or `after`, the text goes to Claude under the heading `# <name>`.
267
+ - With neither, the text follows the mod's state. After a change to `mod.state`, cmod calls `prompt` again, and when the text differs from what Claude last got, it adds `# <name>` and the new text to the conversation as a note Claude reads on its next call to the model, through `mod.session.append`. Several changes in a row give one note with the last text. A note waits until the conversation has started, so a state change before the first prompt changes only the first block.
257
268
  - A `prompt` function that returns `undefined` or `''` adds nothing. One that throws adds nothing, and the error goes to Claude Code's debug log.
258
269
  - `name` cannot be blank. `when` and `after` together throw: add a second `prompt` for the other trigger. Two prompts with one name in a mod throw.
259
270
  - A `when` prompt runs on `prompt.submit` and adds its text to the event it passes down. In a test, `fire('prompt.submit', …)` returns that text in the answer's `context`. [testing.md](testing.md#test-a-prompt-with-when) shows how.
@@ -264,8 +275,10 @@ import { prompt } from '../node_modules/@cmodjs/core/jobs/prompt.js'
264
275
 
265
276
  export const conventions = defineMod({
266
277
  name: 'conventions',
278
+ state: { session: { mode: 'build' } },
267
279
  setup(mod) {
268
280
  mod.use(prompt({ name: 'Commit messages', prompt: 'Write commit messages in the imperative mood.' }))
281
+ mod.use(prompt({ name: 'Mode', prompt: (_input, mod) => `You are in ${mod.state.session.mode} mode.` }))
269
282
  mod.use(prompt({ name: 'Migrations', when: /\bmigrat/i, prompt: 'Every migration has a down step.' }))
270
283
  mod.use(prompt({ name: 'Schema', after: { write: 'db/schema.sql' }, prompt: async (_input, mod) => `The schema is now:\n${await mod.fs.read(`${mod.projectRoot}/db/schema.sql`)}` }))
271
284
  },
@@ -423,6 +436,9 @@ Each member calls the member of the same name in Claude Code's hooks API, `$`, w
423
436
  | `session.usage()` | When the session began, the context window's fill, the rate-limit windows, and the cost, as the status line has them. |
424
437
  | `session.surfaces()` | Every surface the session draws on, `terminal` first. Empty in a plain `-p` run. |
425
438
  | `session.messages(args?)` | What `mod.session.messages` calls. |
439
+ | `session.append({ message, agentId? })` | Adds a message Claude reads to the conversation, or to a subagent's, and resolves `{ message, uuid }` or `{ deny }`. `mod.session.append` is built on it. |
440
+ | `prompt.submit({ text, asUser? })` | Sends a prompt Claude answers as a turn once the session is idle. `mod.session.submit` is built on it. |
441
+ | `model.complete(request, options?)` | What `mod.model.complete` calls. |
426
442
  | `command.register(spec)` | Adds a slash command. `slashCommand` is built on it. |
427
443
  | `tool.register(spec)` | Adds a tool. `tool` is built on it. |
428
444
  | `agent.list()` | The session's subagents and teammates so far. |
package/docs/mod.md CHANGED
@@ -100,15 +100,17 @@ type Mod<State> = {
100
100
  readonly dataFolder: string
101
101
  readonly projectRoot: string
102
102
  readonly cwd: string
103
- on(event, hook): void
103
+ on(event, hook, options?): void
104
104
  use(job): Handle
105
+ every(ms, hook): Timer
105
106
  readonly ui: { pane, render, toast, progress, ask, scroll }
106
107
  readonly process: { run, spawn }
107
108
  readonly fs: { read, write, list, exists, stat }
108
109
  readonly http: { fetch }
109
110
  readonly settings: { read }
110
- readonly session: { messages }
111
+ readonly session: { messages, append, submit }
111
112
  readonly agent: { spawn }
113
+ readonly model: { complete }
112
114
  readonly dependencies: CmodDependencies
113
115
  }
114
116
  ```
@@ -122,15 +124,34 @@ type Mod<State> = {
122
124
  | `cwd` | The session's working folder. It follows a `cd` in a Bash or PowerShell call, and `/cd`. | |
123
125
  | `on` | Adds a hook on a Claude Code event. | [hooks.md](hooks.md) |
124
126
  | `use` | Adds a job, such as a slash command or a tool, and returns its handle. | [jobs.md](jobs.md) |
127
+ | `every` | Runs a function every so many milliseconds while the session runs. | below |
125
128
  | `ui` | Panes, slot renders, toasts, progress lines, questions, and scrolling. | [ui.md](ui.md) |
126
129
  | `process` | Runs a program. | below |
127
130
  | `fs` | Reads, writes, and looks up files. | below |
128
131
  | `http` | Fetches a URL. | below |
129
132
  | `settings` | Reads Claude Code's settings. | below |
130
- | `session` | Reads the conversation, or a subagent's. | below |
133
+ | `session` | Reads the conversation or a subagent's, adds a note Claude reads, and asks Claude for a turn. | below |
131
134
  | `agent` | Starts a subagent. | below |
135
+ | `model` | Asks a model one question, outside the conversation. | below |
132
136
  | `dependencies` | Calls the methods of other mods. | [dependencies.md](dependencies.md) |
133
137
 
138
+ ### mod.every
139
+
140
+ ```ts
141
+ mod.every(ms: number, hook: () => unknown): Timer
142
+ ```
143
+
144
+ `every` runs `hook` every `ms` milliseconds until the session ends or the mod calls `cancel()` on the `Timer` it returns. A tick that comes while the last run is still going is skipped, so a slow run never stacks up. A run that throws logs `<mod>: the every <ms> ms hook failed: <error>`, and the next tick runs again. `ms` is a whole number above 0 and at most `2147483647`, and any other `ms` stops `setup` with the fix.
145
+
146
+ A watch that reacts on its own is `every` plus `mod.state` plus a `prompt` with no trigger: `every` checks the outside world, writes what it found to `mod.state`, and the panes and the prompt follow the state ([jobs.md](jobs.md#prompt)).
147
+
148
+ ```ts
149
+ mod.every(60_000, async () => {
150
+ const { stdout } = await mod.process.run(['gh', 'run', 'list', '--limit', '1', '--json', 'conclusion'])
151
+ mod.state.session.ci = JSON.parse(stdout)[0]?.conclusion ?? 'running'
152
+ })
153
+ ```
154
+
134
155
  ### mod.process
135
156
 
136
157
  ```ts
@@ -221,6 +242,8 @@ export const pluginCount = defineMod({
221
242
  ```ts
222
243
  mod.session.messages(): Promise<SessionMessage[]>
223
244
  mod.session.messages(args: { agentId?: string; as?: 'api' }): Promise<…>
245
+ mod.session.append(text: string): Promise<void>
246
+ mod.session.submit(text: string): Promise<void>
224
247
  ```
225
248
 
226
249
  `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.
@@ -232,6 +255,19 @@ mod.on('SubagentStop', async ({ agent_id }) => {
232
255
  })
233
256
  ```
234
257
 
258
+ `append` adds `text` to the conversation as a user message the person does not see, so Claude reads it on its next call to the model. It starts no turn. It rejects with `<mod>: Claude Code refused the note: <reason>` when a plugin's `session.append` handler refuses it.
259
+
260
+ `submit` sends `text` as the person's next prompt, so Claude answers it as a turn of its own. Claude Code holds it until the session is idle, so it never cuts into a turn. It rejects with `<mod>: Claude Code dropped the prompt: <reason>` when a plugin drops it.
261
+
262
+ ```ts
263
+ mod.every(60_000, async () => {
264
+ const { stdout } = await mod.process.run(['gh', 'run', 'list', '--limit', '1', '--json', 'conclusion'])
265
+ if (JSON.parse(stdout)[0]?.conclusion !== 'failure') return
266
+ await mod.session.append('CI failed on the last push.')
267
+ await mod.session.submit('Find the cause of the CI failure and fix it.')
268
+ })
269
+ ```
270
+
235
271
  ### mod.agent
236
272
 
237
273
  ```ts
@@ -258,6 +294,25 @@ mod.on('UserPromptSubmit', async (input) => {
258
294
  })
259
295
  ```
260
296
 
297
+ ### mod.model
298
+
299
+ ```ts
300
+ mod.model.complete(request: ModelCompleteRequest, options?: { signal?: AbortSignal }): Promise<ModelCompleteResult>
301
+ ```
302
+
303
+ `complete` asks a model one question through Claude Code's own login, outside the conversation, so the answer costs no context and the person sees no turn. The request names the `model`, such as `'haiku'`, and the `prompt`, and takes `system`, `maxTokens`, `effort`, and `timeoutMs`. `timeoutMs` stops a model that is slow or down, so the hook that waits on it still answers in time. `options.signal` stops the call when its `AbortController` aborts.
304
+
305
+ The call never rejects for what the model did. The result's `isAnswered` is true with the reply's `text`, or false with a `reason`, such as `'api-error'` or `'aborted'`.
306
+
307
+ Put a limit on the hook too, with `mod.on`'s `timeoutMs` ([hooks.md](hooks.md#mod-on)), which also covers the work around the call:
308
+
309
+ ```ts
310
+ mod.on('Stop', async ({ last_assistant_message }) => {
311
+ const verdict = await mod.model.complete({ model: 'haiku', prompt: `Does this reply ask the person to do work Claude could do itself? Answer yes or no.\n\n${last_assistant_message}`, timeoutMs: 8000 })
312
+ if (verdict.isAnswered && verdict.text.trim().toLowerCase().startsWith('yes')) return { decision: 'block', reason: 'Do that work yourself, then reply.' }
313
+ }, { timeoutMs: 9000 })
314
+ ```
315
+
261
316
  ## messageOf
262
317
 
263
318
  ```ts
package/docs/testing.md CHANGED
@@ -87,6 +87,11 @@ fire<N extends RoutedEvent>(event: N, input: Args<N>, below?): Promise<EventResu
87
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.
88
88
  - It returns the combined answer in Claude Code's own field names: `additionalContext` is a list, and `decision: 'block'` is `block`.
89
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.
90
+ - `fire('cmod.call', { to, method, input })` calls a method of the mod's `api`, as another mod's `mod.dependencies` does, and returns `{ value }` with what the method returned, or `{ deny }` with the reason it failed.
91
+
92
+ ```ts
93
+ expect(await tested.fire('cmod.call', { to: 'safe-delete', method: 'restore', input: { path: 'a.ts' } })).toEqual({ value: { restored: true } })
94
+ ```
90
95
 
91
96
  ```ts
92
97
  import { expect, test } from 'bun:test'
@@ -145,6 +150,7 @@ Waits until the work a hook started without awaiting it has run, such as a `void
145
150
  ### lines
146
151
 
147
152
  - `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.
153
+ - A `Button` draws as Claude Code draws it: `[ Save ]`, or `1: Save` when it is `plain` with a `hotkey`, or `Save` when it is `plain` alone. Borders and colors are not drawn.
148
154
  - `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.
149
155
 
150
156
  ```ts
@@ -208,7 +214,9 @@ type Fakes = {
208
214
  http: { fetch? }
209
215
  settings: { read? }
210
216
  ui: { ask?, open?, scroll? }
211
- session: { messages? }
217
+ session: { messages?, append? }
218
+ prompt: { submit? }
219
+ model: { complete? }
212
220
  agent: { list?, spawn? }
213
221
  clock: { after?, every? }
214
222
  cmod: { call? }
@@ -222,7 +230,9 @@ Set a fake to answer a call:
222
230
  - `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.
223
231
  - `ui.open` places every pane by default. A fake that answers `{ isPlaced: false }` holds a pane back.
224
232
  - `ui.scroll` answers `{}` by default, as when the window moved. A fake that answers `{ deny }` refuses the scroll.
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.
233
+ - `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, so a test of `mod.every` keeps it and calls it once per tick.
234
+ - `session.append` and `prompt.submit` take every note and prompt by default, into `shown.notes` and `shown.prompts`. A fake that answers `{ deny }` or `{ drop }` refuses one.
235
+ - `model.complete` has no default: set it to the answer the model gives, such as `async () => ({ isAnswered: true, text: 'no', usage })`.
226
236
 
227
237
  ```ts
228
238
  import { expect, test } from 'bun:test'
@@ -245,6 +255,8 @@ test('a Commit answer commits', async () => {
245
255
  ```ts
246
256
  type Shown = {
247
257
  readonly toasts: string[]
258
+ readonly notes: string[]
259
+ readonly prompts: string[]
248
260
  readonly logs: string[]
249
261
  readonly debug: string[]
250
262
  readonly statuses: (string | undefined)[]
@@ -254,7 +266,19 @@ type Shown = {
254
266
  }
255
267
  ```
256
268
 
257
- What the person would see: the toasts, the log lines, the debug log lines, each status line text, the open panes, and the slash commands and tools the mod added.
269
+ What the person and Claude would see: the toasts, the notes the mod added for Claude with `mod.session.append` or a `prompt` that follows the state, the prompts it sent with `mod.session.submit`, the log lines, the debug log lines, each status line text, the open panes, and the slash commands and tools the mod added.
270
+
271
+ ```ts
272
+ test('the Mode prompt follows the mode', async () => {
273
+ const tested = testMod(modes)
274
+ await tested.fire('prompt.context', { blocks: [] }, { blocks: [] })
275
+
276
+ tested.state.session.mode = 'build'
277
+ await tested.settle()
278
+
279
+ expect(tested.shown.notes).toEqual(['# Mode\nYou are in build mode.'])
280
+ })
281
+ ```
258
282
 
259
283
  `testing.js` exports the types `TestOptions`, `TestInput`, `TestedMod`, `Fakes`, `Shown`, and `TestCall`, so a test helper can take them.
260
284
 
package/jobs/context.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { JobContext, Mod } from '../mod.js';
2
2
  import { type Deadline } from '../runtime/deadline.js';
3
- import type { ToolCall, ToolUse } from '../utils/call-effects.js';
3
+ import type { ToolCall } from '../utils/call-effects.js';
4
4
  import { type Workspace } from './permissions/find-project-scope.js';
5
5
  import { type Target } from './permissions/match-target.js';
6
6
  export declare function workspaceReader({ claude }: JobContext): () => Promise<Workspace>;
7
- export declare function afterCall<State extends object>(job: JobContext<State>, contextAfter: (use: ToolUse, workspace: Workspace) => Promise<readonly string[]>, failed: (error: unknown) => readonly string[]): void;
7
+ export declare function afterCall<State extends object>(job: JobContext<State>, contextAfter: (call: ToolCall, workspace: Workspace) => Promise<readonly string[]>, failed: (error: unknown) => readonly string[]): void;
8
8
  export declare function modOf<State extends object>(job: JobContext<State>, { call, folder }: {
9
9
  readonly call?: ToolCall;
10
10
  readonly folder: string;
@@ -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,CA0BvL;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,MAAM,0BAA0B,CAAA;AACxD,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,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,EAClF,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
@@ -92,8 +92,8 @@ function spawnWithin(claude, deadline, started) {
92
92
  return Object.assign(pieces(), { result });
93
93
  }
94
94
  export function targetWords(target) {
95
- const { key, patterns } = targetOf(target);
96
- const named = listed(patterns);
95
+ const { key, patterns, exclusions } = targetOf(target);
96
+ const named = exclusions.length === 0 ? listed(patterns) : `${listed(patterns)} except ${listed(exclusions)}`;
97
97
  if (key === 'command')
98
98
  return patterns.includes('*') ? 'every shell command' : named;
99
99
  if (key === 'write')
@@ -1,4 +1,4 @@
1
- import { type ToolCall, type ToolUse } from '../../utils/call-effects.js';
1
+ import { type ToolCall } from '../../utils/call-effects.js';
2
2
  import type { Workspace } from './find-project-scope.js';
3
3
  import { type TargetCalls, type TargetKey, type TargetOf } from './match-target.js';
4
4
  export type Rule<Mod> = {
@@ -17,5 +17,5 @@ export type Verdict = {
17
17
  reason?: string;
18
18
  };
19
19
  export declare function stricterVerdict<Result extends Verdict>(first: Result, second: Result): Result;
20
- export declare function decidePermission<Mod>(rules: PermissionRules<Mod>, use: ToolUse, workspace: Workspace, modOf: (call: ToolCall, folder: string) => Mod): Promise<Verdict | undefined>;
20
+ export declare function decidePermission<Mod>(rules: PermissionRules<Mod>, use: ToolCall, workspace: Workspace, modOf: (call: ToolCall, folder: string) => Mod): Promise<Verdict | undefined>;
21
21
  //# sourceMappingURL=decide-permission.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"decide-permission.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/decide-permission.ts"],"names":[],"mappings":"AAEA,OAAO,EAA6C,KAAK,QAAQ,EAAE,KAAK,OAAO,EAAE,MAAM,6BAA6B,CAAA;AACpH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;AACxD,OAAO,EAAuE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AAGxJ,MAAM,MAAM,IAAI,CAAC,GAAG,IAAI;KACrB,GAAG,IAAI,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG;QAClC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;QACvE,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB;CACF,CAAC,SAAS,CAAC,CAAA;AAEZ,MAAM,MAAM,eAAe,CAAC,GAAG,IAAI;IAAE,IAAI,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IAAC,GAAG,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CAAA;CAAE,CAAA;AAC5E,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAA;AAC/C,MAAM,MAAM,OAAO,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAI7D,wBAAgB,eAAe,CAAC,MAAM,SAAS,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE7F;AAED,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,KAAK,EAAE,eAAe,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,KAAK,GAAG,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAezL"}
1
+ {"version":3,"file":"decide-permission.d.ts","sourceRoot":"","sources":["../../src/jobs/permissions/decide-permission.ts"],"names":[],"mappings":"AAEA,OAAO,EAA6C,KAAK,QAAQ,EAAE,MAAM,6BAA6B,CAAA;AACtG,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;AACxD,OAAO,EAAuE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AAGxJ,MAAM,MAAM,IAAI,CAAC,GAAG,IAAI;KACrB,GAAG,IAAI,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG;QAClC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;QACvE,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB;CACF,CAAC,SAAS,CAAC,CAAA;AAEZ,MAAM,MAAM,eAAe,CAAC,GAAG,IAAI;IAAE,IAAI,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IAAC,GAAG,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CAAA;CAAE,CAAA;AAC5E,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAA;AAC/C,MAAM,MAAM,OAAO,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAI7D,wBAAgB,eAAe,CAAC,MAAM,SAAS,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE7F;AAED,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,KAAK,EAAE,eAAe,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,KAAK,GAAG,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAe1L"}
@@ -1,4 +1,4 @@
1
- import { type CallBase, type CallEffects, type CommandCall, type FetchCall, type FileAccess, type FileCall, type ToolCall, type ToolUse } from '../../utils/call-effects.js';
1
+ import { type CallBase, type CallEffects, type CommandCall, type FetchCall, type FileAccess, type FileCall, type ToolCall } from '../../utils/call-effects.js';
2
2
  import type { Workspace } from './find-project-scope.js';
3
3
  export type TargetCalls = {
4
4
  command: CommandCall;
@@ -21,7 +21,8 @@ export type MatchedCall = {
21
21
  export declare function targetOf(target: Target): {
22
22
  key: TargetKey;
23
23
  patterns: string[];
24
+ exclusions: string[];
24
25
  };
25
- export declare function matchTarget(target: Target, use: ToolUse, effects: CallEffects, workspace: Workspace): Promise<MatchedCall[]>;
26
+ export declare function matchTarget(target: Target, call: CallBase, effects: CallEffects, workspace: Workspace): Promise<MatchedCall[]>;
26
27
  export declare function folderInScope(shell: NonNullable<CallEffects['shell']>, workspace: Workspace): Promise<string | undefined>;
27
28
  //# sourceMappingURL=match-target.d.ts.map
@@ -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;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
+ {"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,MAAM,6BAA6B,CAAA;AAC1K,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,CAAC;IAAC,UAAU,EAAE,MAAM,EAAE,CAAA;CAAE,CAWrG;AAED,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAkCpI;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"}