@cmodjs/core 0.1.11 → 0.1.13

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 (66) hide show
  1. package/docs/commands.md +21 -14
  2. package/docs/dependencies.md +8 -0
  3. package/docs/hooks.md +10 -1
  4. package/docs/index.md +7 -4
  5. package/docs/install-steps.md +22 -4
  6. package/docs/jobs.md +55 -8
  7. package/docs/mod.md +58 -3
  8. package/docs/testing.md +29 -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/jobs/schedule.d.ts +7 -0
  23. package/jobs/schedule.d.ts.map +1 -0
  24. package/jobs/schedule.js +66 -0
  25. package/mod.d.ts +11 -2
  26. package/mod.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/records.d.ts +5 -0
  29. package/records.d.ts.map +1 -1
  30. package/records.js +30 -6
  31. package/register.d.ts.map +1 -1
  32. package/register.js +4 -1
  33. package/runtime/claude.d.ts +8 -0
  34. package/runtime/claude.d.ts.map +1 -1
  35. package/runtime/hooks.js +4 -4
  36. package/runtime/lifecycle.d.ts +1 -0
  37. package/runtime/lifecycle.d.ts.map +1 -1
  38. package/runtime/lifecycle.js +92 -16
  39. package/runtime/state.d.ts +2 -1
  40. package/runtime/state.d.ts.map +1 -1
  41. package/runtime/state.js +12 -1
  42. package/src/jobs/check.ts +2 -2
  43. package/src/jobs/context.ts +5 -5
  44. package/src/jobs/permissions/decide-permission.ts +4 -4
  45. package/src/jobs/permissions/match-target.ts +24 -16
  46. package/src/jobs/permissions.ts +18 -7
  47. package/src/jobs/prompt.ts +31 -3
  48. package/src/jobs/schedule.ts +65 -0
  49. package/src/mod.ts +15 -1
  50. package/src/records.ts +32 -8
  51. package/src/register.ts +5 -2
  52. package/src/runtime/claude.ts +4 -1
  53. package/src/runtime/hooks.ts +5 -5
  54. package/src/runtime/lifecycle.ts +101 -15
  55. package/src/runtime/state.ts +13 -1
  56. package/src/testing/fake-claude.ts +40 -3
  57. package/src/testing.ts +1 -0
  58. package/src/utils/call-effects.ts +13 -11
  59. package/testing/fake-claude.d.ts +18 -0
  60. package/testing/fake-claude.d.ts.map +1 -1
  61. package/testing/fake-claude.js +32 -2
  62. package/testing.d.ts.map +1 -1
  63. package/testing.js +1 -0
  64. package/utils/call-effects.d.ts +4 -8
  65. package/utils/call-effects.d.ts.map +1 -1
  66. package/utils/call-effects.js +6 -3
package/docs/commands.md CHANGED
@@ -50,29 +50,36 @@ 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 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 stays logged in as you, with no login screen:
73
+ - On macOS, Claude Code reads your login from the keychain, which `cmod try` links into the new home as `~/Library/Keychains`. Deleting the home removes only the link.
74
+ - Elsewhere, `CLAUDE_SECURESTORAGE_CONFIG_DIR` points Claude Code at the folder that holds your login, so it reads and refreshes that login where it is.
75
+ - `ANTHROPIC_CONFIG_DIR` points at your `~/.config/anthropic` login profiles, and `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY` pass through.
76
+ - The session keeps how Claude Code reaches the model and what it must never do, so a try is as safe as your own session. From your `settings.json` it keeps `env` (a proxy or `ANTHROPIC_BASE_URL`, without `cmod link`'s `CLAUDE_CODE_PLUGIN_DIRS`), the credential helpers (`apiKeyHelper`, `awsAuthRefresh`, `awsCredentialExport`, `gcpAuthRefresh`, `otelHeadersHelper`, `proxyAuthHelper`), the login pins (`forceLoginMethod`, `forceLoginOrgUUID`, `forceLoginGatewayUrl`, `gatewayInternalNetworks`), `sandbox`, and `permissions.deny`. From `~/.claude.json` it keeps your account and the API keys you approved. A `~/` in a helper still means your own home. Hooks, plugins, allow rules, the model, and every other preference stay behind. Managed settings apply as always.
77
+ - `--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
78
  - 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.
79
+ - 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
80
  - 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"`.
81
+ - A mod another folder has set up in that home is refused, so `cmod try` never replaces an installed copy.
82
+ - Arguments after `--` go to `claude`, such as `-- -p "hello"`. An agent tests a mod with one `cmod try <path> --yes -- -p "<prompt>"` run.
76
83
 
77
84
  ### cmod publish
78
85
 
@@ -82,7 +89,7 @@ cmod publish [path] [--dry-run]
82
89
 
83
90
  Releases the mod at `path` (default: the current folder) at the version in its `plugin.json`:
84
91
 
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.
92
+ 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
93
  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
94
  3. Checks the release with `claude plugin validate --strict`, and stops with the validator's message when it fails.
88
95
  4. Commits the release as the `release` branch, and builds the release archive from that commit.
@@ -106,7 +113,7 @@ It needs, and refuses to start without:
106
113
  - the network, to hash the cmod plugin release it lists, unless the mod is cmod itself
107
114
  - for a real publish, a working tree with no uncommitted changes, a `v<version>` tag that does not exist yet, a `git push` to `origin` that succeeds, and the `gh` command, which creates the GitHub release
108
115
 
109
- `--dry-run` builds and writes everything, and pushes nothing. It allows uncommitted changes and an existing tag, and builds from the last commit. It prints the folder that holds the release, so you can read what the directory will read.
116
+ `--dry-run` builds everything into a temporary folder, and pushes nothing. It writes `marketplace.json` there too, so the working tree stays as it was. It allows uncommitted changes and an existing tag, and builds from the last commit. It prints the folder that holds the release, so you can read what the directory will read.
110
117
 
111
118
  ## Install mods
112
119
 
@@ -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,16 @@ 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) |
59
+ | How does a mod give Claude a prompt on a schedule, like `/loop`? | [jobs.md](jobs.md#schedule) |
58
60
  | How do I keep values across prompts, sessions, and projects? How does a person change them? | [state.md](state.md) |
59
61
  | How do I react to a Claude Code event, and what can a hook answer? | [hooks.md](hooks.md) |
60
62
  | How do I draw a pane, change a row Claude Code draws, or ask the person something? | [ui.md](ui.md) |
61
- | 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) |
63
+ | How do I add a slash command, a tool, a permission rule, a check, a prompt, a schedule, a status line, or a background program? | [jobs.md](jobs.md) |
62
64
  | 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) |
65
+ | 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
66
  | 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) |
67
+ | Which `cmod` command links, checks, tries in a clean Claude Code, or publishes a mod? | [commands.md](commands.md) |
66
68
 
67
69
  ## The Public API
68
70
 
@@ -72,7 +74,7 @@ A mod imports from the files below, and every other file in this package is inte
72
74
  | --- | --- | --- |
73
75
  | `mod.js` | `defineMod`, `ModDefinition`, `Mod`, `JobContext`, `Job`, `ProgressStep`, `PaneHandle`, `longestMs`, `messageOf` | [mod.md](mod.md), [jobs.md](jobs.md), [ui.md](ui.md) |
74
76
  | `mod.js` | `Claude`, `RoutedEvent`, `RoutedHook`, `ToolCalls` | [jobs.md](jobs.md) |
75
- | `mod.js` | `ModEvent`, `ModHook`, `HookInput`, `HookAnswer` | [hooks.md](hooks.md) |
77
+ | `mod.js` | `ModEvent`, `ModHook`, `HookOptions`, `HookInput`, `HookAnswer` | [hooks.md](hooks.md) |
76
78
  | `mod.js` | `notInstalled` | [dependencies.md](dependencies.md) |
77
79
  | `register.js` | `registerMod`, `registerPermissionCheck` | [mod.md](mod.md) |
78
80
  | `shell.js` | `parseShell`, `ParsedShell`, `ShellCommand` | [hooks.md](hooks.md) |
@@ -88,6 +90,7 @@ A mod imports from the files below, and every other file in this package is inte
88
90
  | `jobs/permissions.js` | `permissions`, `Rule`, `PermissionRules`, `Target`, `CommandCall`, `FileCall`, `FetchCall`, `ToolCall` | [jobs.md](jobs.md) |
89
91
  | `jobs/check.js` | `check` | [jobs.md](jobs.md) |
90
92
  | `jobs/prompt.js` | `prompt` | [jobs.md](jobs.md) |
93
+ | `jobs/schedule.js` | `schedule`, `Schedule` | [jobs.md](jobs.md) |
91
94
  | `jobs/status-line.js` | `statusLine`, `Usage` | [jobs.md](jobs.md) |
92
95
  | `jobs/program.js` | `program`, `Program` | [jobs.md](jobs.md) |
93
96
  | `jobs/context.js` | `afterCall`, `modWithin`, `modOf`, `workspaceReader` | [jobs.md](jobs.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,35 @@ 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
- - 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.
41
+ - In a terminal, `cmod install`, `cmod link`, `cmod try`, and `cmod update` print the commands, the program, and the keys, and ask `Set up <mod>? [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
+ In Claude Code, the mod sets itself up through the `cmod` that PATH finds, and only through one that knows every step it has. A mod with `keys` waits for cmod 0.1.12 or later, because an older cmod skips the keys. While PATH finds an older one, the line above the prompt says `PATH finds cmod <version>, and <mod> needs cmod 0.1.12 or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.` The mod sets up as soon as a new enough cmod answers, such as the one the cmod plugin installs into `~/.local/bin` at its first start.
47
+
48
+ ## Key bindings
49
+
50
+ 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:
51
+
52
+ ```json
53
+ { "bindings": [{ "context": "Chat", "bindings": { "shift+tab": "command:mode" } }] }
54
+ ```
55
+
56
+ - 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.
57
+ - 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.`
58
+ - 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.
59
+ - 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.
60
+
43
61
  ## How a step runs
44
62
 
45
63
  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
@@ -10,7 +10,7 @@ type Job<Handle, State> = (context: JobContext<State>) => Handle
10
10
 
11
11
  Each job function, such as `slashCommand(options)`, returns a `Job`. `mod.use` runs the job and returns its handle. A job that has nothing to hand back returns `void`.
12
12
 
13
- A job's bad options throw at one of two times. `check`, `permissions`, and `prompt` check their options when the job function is called. `slashCommand`, `tool`, `statusLine`, and `program` check theirs when the job runs, at `mod.use`, so the error surfaces as `its setup function threw: …`.
13
+ A job's bad options throw at one of two times. `check`, `permissions`, `prompt`, and `schedule` check their options when the job function is called. `slashCommand`, `tool`, `statusLine`, and `program` check theirs when the job runs, at `mod.use`, so the error surfaces as `its setup function threw: …`.
14
14
 
15
15
  | Job | Import | Adds |
16
16
  | --- | --- | --- |
@@ -18,7 +18,8 @@ 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
+ | `schedule` | `jobs/schedule.js` | A prompt Claude Code sends Claude on a cron schedule, kept current with the mod's state |
22
23
  | `statusLine` | `jobs/status-line.js` | A status line |
23
24
  | `program` | `jobs/program.js` | A background program the mod talks to over HTTP |
24
25
 
@@ -57,6 +58,12 @@ type Reply = string | { text?: string; context?: string } | undefined
57
58
  - `description` is the line the typeahead and `/help` show.
58
59
  - `argumentHint` is drawn dim after the name, such as `[path]`.
59
60
  - `immediate: true` runs the command at once while Claude is still working. Without it, the command waits for the turn to end.
61
+ - 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.
62
+ - 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.
63
+
64
+ ```ts
65
+ 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)) }))
66
+ ```
60
67
  - `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
68
  - 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
69
  - A `reply` that throws shows `/<name> failed: <error>`.
@@ -142,6 +149,7 @@ export const tickets = defineMod({
142
149
  | `{ tool: 'mcp__tickets__*' }` | A call of a matching tool name |
143
150
 
144
151
  - A `read`, `write`, `fetch`, `subagent`, or `tool` pattern is a glob, matched without regard to case.
152
+ - 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
153
  - 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
154
  - 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
155
 
@@ -160,7 +168,9 @@ A `command` pattern is not a glob. cmod splits it into words the way it splits a
160
168
  ## permissions
161
169
 
162
170
  ```ts
163
- permissions<State>(rules: { deny?: Rule<State>[]; ask?: Rule<State>[] }): Job<void, State>
171
+ permissions<State>(rules: PermissionRules<State> | ((state: State) => PermissionRules<State>)): Job<void, State>
172
+
173
+ type PermissionRules<State> = { deny?: Rule<State>[]; ask?: Rule<State>[] }
164
174
 
165
175
  type Rule<State> = Target & {
166
176
  when?: (call: <the call of the target's key>, mod: Mod<State>) => boolean | Promise<boolean>
@@ -169,23 +179,24 @@ type Rule<State> = Target & {
169
179
  ```
170
180
 
171
181
  - 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.
182
+ - 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
183
  - 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
184
  - `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
185
  - 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.
186
+ - 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
187
  - `permissions({})` throws `permissions: give it a deny or an ask rule, or remove it from setup.`
177
188
  - 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
189
 
179
- `when` gets the call, typed by the target's key. `jobs/permissions.js` exports each type:
190
+ `when` gets the call, typed by the target's key. `jobs/permissions.js` exports each type, and `ToolCall` is any of them:
180
191
 
181
- | Key | Call type | Fields beyond `tool`, `agentId`, and `agentType` |
192
+ | Key | Call type | Fields beyond `tool`, `input`, `agentId`, and `agentType` |
182
193
  | --- | --- | --- |
183
194
  | `command` | `CommandCall` | `commands`, each `[program, ...arguments]`, and `isFullyParsed` |
184
195
  | `read`, `write` | `FileCall` | `path`, and for an `Edit` or a `Write`, `content`, the new text, and `previousContent`, the text before |
185
196
  | `fetch` | `FetchCall` | `url` |
186
197
  | `subagent`, `tool` | `ToolCall` | none |
187
198
 
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`.
199
+ `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
200
 
190
201
  ```ts
191
202
  import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
@@ -249,11 +260,12 @@ prompt<State>(options: {
249
260
 
250
261
  | Options | When Claude reads the text |
251
262
  | --- | --- |
252
- | neither | Once per conversation, as a block of context named `name`. A block of that name already in the context wins. |
263
+ | 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
264
  | `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
265
  | `after` | After each call that matches a target and succeeds, with the call's result. `input.call` is the matched call. |
255
266
 
256
267
  - With `when` or `after`, the text goes to Claude under the heading `# <name>`.
268
+ - 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
269
  - 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
270
  - `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
271
  - 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,14 +276,45 @@ import { prompt } from '../node_modules/@cmodjs/core/jobs/prompt.js'
264
276
 
265
277
  export const conventions = defineMod({
266
278
  name: 'conventions',
279
+ state: { session: { mode: 'build' } },
267
280
  setup(mod) {
268
281
  mod.use(prompt({ name: 'Commit messages', prompt: 'Write commit messages in the imperative mood.' }))
282
+ mod.use(prompt({ name: 'Mode', prompt: (_input, mod) => `You are in ${mod.state.session.mode} mode.` }))
269
283
  mod.use(prompt({ name: 'Migrations', when: /\bmigrat/i, prompt: 'Every migration has a down step.' }))
270
284
  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
285
  },
272
286
  })
273
287
  ```
274
288
 
289
+ ## schedule
290
+
291
+ ```ts
292
+ schedule<State>(entry: Schedule | ((state: State) => Schedule | undefined)): Job<void, State>
293
+
294
+ type Schedule = { readonly cron: string; readonly prompt: string }
295
+ ```
296
+
297
+ `schedule` gives Claude a prompt on a schedule, through the cron jobs Claude Code keeps for `/loop`. `cron` is a standard 5-field cron in local time, such as `*/10 * * * *` for every 10 minutes. At each fire Claude Code shows one `Running scheduled task` row and Claude answers the prompt.
298
+
299
+ - A function of the state follows it. After each change to `mod.state`, cmod calls it again, and when its answer differs, cmod deletes the cron and makes the new one. `undefined` deletes the cron and makes none, which is how a mod pauses.
300
+ - Claude Code fires a cron only while Claude is idle, on whole minutes, up to a tenth of the period late. A recurring cron expires after 7 days.
301
+ - A cron lasts only as long as the session. The job makes it again each time the mod starts, and deletes the one it made before `/reload-plugins`, so a cron is never doubled.
302
+ - A blank `cron` or `prompt` throws. A function that throws keeps the last cron, and a cron Claude Code refuses writes a log line naming why.
303
+ - Work that needs no turn of Claude's, such as polling a file, belongs in `mod.every`. A mod pauses a schedule by turning it off: a hook that drops a fire still leaves two rows, `Running scheduled task` and `Prompt dropped by a hook:`.
304
+
305
+ ```ts
306
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
307
+ import { schedule } from '../node_modules/@cmodjs/core/jobs/schedule.js'
308
+
309
+ export const loop = defineMod({
310
+ name: 'loop',
311
+ state: { project: { isOn: false, cron: '*/10 * * * *', prompt: 'Check the CI and fix what failed.' } },
312
+ setup(mod) {
313
+ mod.use(schedule((state) => (state.project.isOn ? { cron: state.project.cron, prompt: state.project.prompt } : undefined)))
314
+ },
315
+ })
316
+ ```
317
+
275
318
  ## statusLine
276
319
 
277
320
  ```ts
@@ -423,8 +466,12 @@ Each member calls the member of the same name in Claude Code's hooks API, `$`, w
423
466
  | `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
467
  | `session.surfaces()` | Every surface the session draws on, `terminal` first. Empty in a plain `-p` run. |
425
468
  | `session.messages(args?)` | What `mod.session.messages` calls. |
469
+ | `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. |
470
+ | `prompt.submit({ text, asUser? })` | Sends a prompt Claude answers as a turn once the session is idle. `mod.session.submit` is built on it. |
471
+ | `model.complete(request, options?)` | What `mod.model.complete` calls. |
426
472
  | `command.register(spec)` | Adds a slash command. `slashCommand` is built on it. |
427
473
  | `tool.register(spec)` | Adds a tool. `tool` is built on it. |
474
+ | `tool.call({ tool, ...input })` | Calls a tool the way Claude does, through the permission check, and resolves `{ result, text, isError? }` or `{ deny }`. `schedule` is built on it, calling `CronCreate` and `CronDelete`. |
428
475
  | `agent.list()` | The session's subagents and teammates so far. |
429
476
  | `agent.spawn(args)` | What `mod.agent.spawn` calls. |
430
477
  | `env.home()` | The `HOME` variable, or `undefined` when it is unset. |
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, and shows it under `The <mod> plugin sent a message:`. It rejects with `<mod>: Claude Code dropped the prompt: <reason>` when a plugin drops it. A prompt that comes on a clock, such as every 10 minutes, belongs in a `schedule` job instead, which Claude Code shows as one `Running scheduled task` row ([jobs.md](jobs.md#schedule)).
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'
@@ -209,10 +214,13 @@ type Fakes = {
209
214
  http: { fetch? }
210
215
  settings: { read? }
211
216
  ui: { ask?, open?, scroll? }
212
- session: { messages? }
217
+ session: { messages?, append? }
218
+ prompt: { submit? }
219
+ model: { complete? }
213
220
  agent: { list?, spawn? }
214
221
  clock: { after?, every? }
215
222
  cmod: { call? }
223
+ tool: { call? }
216
224
  }
217
225
  ```
218
226
 
@@ -223,7 +231,10 @@ Set a fake to answer a call:
223
231
  - `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.
224
232
  - `ui.open` places every pane by default. A fake that answers `{ isPlaced: false }` holds a pane back.
225
233
  - `ui.scroll` answers `{}` by default, as when the window moved. A fake that answers `{ deny }` refuses the scroll.
226
- - `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.
234
+ - `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.
235
+ - `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.
236
+ - `model.complete` has no default: set it to the answer the model gives, such as `async () => ({ isAnswered: true, text: 'no', usage })`.
237
+ - `tool.call` answers `CronCreate`, `CronDelete`, and `CronList` by default, keeping the crons a `schedule` makes in `shown.schedules`. A call to any other tool needs a fake.
227
238
 
228
239
  ```ts
229
240
  import { expect, test } from 'bun:test'
@@ -246,16 +257,31 @@ test('a Commit answer commits', async () => {
246
257
  ```ts
247
258
  type Shown = {
248
259
  readonly toasts: string[]
260
+ readonly notes: string[]
261
+ readonly prompts: string[]
249
262
  readonly logs: string[]
250
263
  readonly debug: string[]
251
264
  readonly statuses: (string | undefined)[]
252
265
  readonly openPanes: Set<string>
253
266
  readonly commands: string[]
254
267
  readonly tools: string[]
268
+ readonly schedules: { readonly id: string; readonly cron: string; readonly prompt: string }[]
255
269
  }
256
270
  ```
257
271
 
258
- 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.
272
+ 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, the slash commands and tools the mod added, and the crons its `schedule` keeps now.
273
+
274
+ ```ts
275
+ test('the Mode prompt follows the mode', async () => {
276
+ const tested = testMod(modes)
277
+ await tested.fire('prompt.context', { blocks: [] }, { blocks: [] })
278
+
279
+ tested.state.session.mode = 'build'
280
+ await tested.settle()
281
+
282
+ expect(tested.shown.notes).toEqual(['# Mode\nYou are in build mode.'])
283
+ })
284
+ ```
259
285
 
260
286
  `testing.js` exports the types `TestOptions`, `TestInput`, `TestedMod`, `Fakes`, `Shown`, and `TestCall`, so a test helper can take them.
261
287
 
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"}