@cmodjs/core 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/docs/commands.md +50 -10
  2. package/docs/dependencies.md +12 -12
  3. package/docs/hooks.md +39 -27
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +9 -7
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +200 -174
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +248 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +65 -22
  13. package/docs/ui.md +108 -76
  14. package/jobs/check.d.ts.map +1 -1
  15. package/jobs/check.js +5 -2
  16. package/jobs/context.d.ts +7 -8
  17. package/jobs/context.d.ts.map +1 -1
  18. package/jobs/context.js +23 -73
  19. package/jobs/permissions/decide-permission.d.ts +5 -3
  20. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  21. package/jobs/permissions/decide-permission.js +6 -4
  22. package/jobs/permissions/find-project-scope.d.ts +1 -1
  23. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  24. package/jobs/permissions/find-project-scope.js +8 -11
  25. package/jobs/permissions/match-target.d.ts +2 -2
  26. package/jobs/permissions/match-target.d.ts.map +1 -1
  27. package/jobs/permissions.d.ts +1 -1
  28. package/jobs/permissions.d.ts.map +1 -1
  29. package/jobs/permissions.js +6 -4
  30. package/jobs/program.d.ts.map +1 -1
  31. package/jobs/program.js +8 -3
  32. package/jobs/prompt.d.ts +4 -2
  33. package/jobs/prompt.d.ts.map +1 -1
  34. package/jobs/prompt.js +16 -15
  35. package/jobs/schedule.d.ts +2 -2
  36. package/jobs/schedule.d.ts.map +1 -1
  37. package/jobs/schedule.js +9 -9
  38. package/jobs/slash-command.d.ts +4 -4
  39. package/jobs/slash-command.d.ts.map +1 -1
  40. package/jobs/slash-command.js +14 -9
  41. package/jobs/status-line.d.ts +1 -11
  42. package/jobs/status-line.d.ts.map +1 -1
  43. package/jobs/status-line.js +8 -12
  44. package/jobs/tool.d.ts +2 -2
  45. package/jobs/tool.d.ts.map +1 -1
  46. package/jobs/tool.js +15 -9
  47. package/mod.d.ts +154 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +94 -3
  50. package/options.d.ts +2 -2
  51. package/options.js +19 -19
  52. package/package.json +3 -3
  53. package/records.d.ts +10 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +28 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +17 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +66 -0
  61. package/runtime/bound.d.ts +4 -0
  62. package/runtime/bound.d.ts.map +1 -0
  63. package/runtime/bound.js +9 -0
  64. package/runtime/claude.d.ts +3 -2
  65. package/runtime/claude.d.ts.map +1 -1
  66. package/runtime/conversations.d.ts +16 -0
  67. package/runtime/conversations.d.ts.map +1 -0
  68. package/runtime/conversations.js +291 -0
  69. package/runtime/deadline.d.ts +0 -1
  70. package/runtime/deadline.d.ts.map +1 -1
  71. package/runtime/deadline.js +1 -1
  72. package/runtime/dependencies.d.ts +2 -0
  73. package/runtime/dependencies.d.ts.map +1 -1
  74. package/runtime/dependencies.js +9 -0
  75. package/runtime/engine.d.ts +17 -0
  76. package/runtime/engine.d.ts.map +1 -0
  77. package/runtime/engine.js +10 -0
  78. package/runtime/grants.d.ts +4 -0
  79. package/runtime/grants.d.ts.map +1 -1
  80. package/runtime/grants.js +63 -26
  81. package/runtime/hooks.d.ts +5 -4
  82. package/runtime/hooks.d.ts.map +1 -1
  83. package/runtime/hooks.js +10 -5
  84. package/runtime/installer.d.ts.map +1 -1
  85. package/runtime/installer.js +11 -9
  86. package/runtime/lifecycle.d.ts +3 -1
  87. package/runtime/lifecycle.d.ts.map +1 -1
  88. package/runtime/lifecycle.js +316 -185
  89. package/runtime/metadata.d.ts +3 -1
  90. package/runtime/metadata.d.ts.map +1 -1
  91. package/runtime/metadata.js +21 -11
  92. package/runtime/options.d.ts +3 -1
  93. package/runtime/options.d.ts.map +1 -1
  94. package/runtime/options.js +4 -4
  95. package/runtime/programs.d.ts +8 -0
  96. package/runtime/programs.d.ts.map +1 -1
  97. package/runtime/programs.js +39 -1
  98. package/runtime/router.d.ts +2 -1
  99. package/runtime/router.d.ts.map +1 -1
  100. package/runtime/router.js +11 -3
  101. package/runtime/sessions.d.ts +36 -0
  102. package/runtime/sessions.d.ts.map +1 -0
  103. package/runtime/sessions.js +235 -0
  104. package/runtime/state.d.ts +6 -2
  105. package/runtime/state.d.ts.map +1 -1
  106. package/runtime/state.js +46 -13
  107. package/runtime/toaster.d.ts +31 -0
  108. package/runtime/toaster.d.ts.map +1 -0
  109. package/runtime/toaster.js +176 -0
  110. package/runtime/ui.d.ts +6 -17
  111. package/runtime/ui.d.ts.map +1 -1
  112. package/runtime/ui.js +36 -112
  113. package/session/context/context.d.ts +10 -0
  114. package/session/context/context.d.ts.map +1 -0
  115. package/session/context/context.js +2 -0
  116. package/session/context/turn/message/document.d.ts +7 -0
  117. package/session/context/turn/message/document.d.ts.map +1 -0
  118. package/session/context/turn/message/document.js +1 -0
  119. package/session/context/turn/message/message.d.ts +54 -0
  120. package/session/context/turn/message/message.d.ts.map +1 -0
  121. package/session/context/turn/message/message.js +137 -0
  122. package/session/context/turn/message/note.d.ts +8 -0
  123. package/session/context/turn/message/note.d.ts.map +1 -0
  124. package/session/context/turn/message/note.js +1 -0
  125. package/session/context/turn/message/notice.d.ts +6 -0
  126. package/session/context/turn/message/notice.d.ts.map +1 -0
  127. package/session/context/turn/message/notice.js +1 -0
  128. package/session/context/turn/message/prompt.d.ts +8 -0
  129. package/session/context/turn/message/prompt.d.ts.map +1 -0
  130. package/session/context/turn/message/prompt.js +1 -0
  131. package/session/context/turn/message/reply.d.ts +6 -0
  132. package/session/context/turn/message/reply.d.ts.map +1 -0
  133. package/session/context/turn/message/reply.js +1 -0
  134. package/session/context/turn/message/sender.d.ts +14 -0
  135. package/session/context/turn/message/sender.d.ts.map +1 -0
  136. package/session/context/turn/message/sender.js +30 -0
  137. package/session/context/turn/message/skill.d.ts +7 -0
  138. package/session/context/turn/message/skill.d.ts.map +1 -0
  139. package/session/context/turn/message/skill.js +1 -0
  140. package/session/context/turn/message/tool-call.d.ts +9 -0
  141. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  142. package/session/context/turn/message/tool-call.js +1 -0
  143. package/session/context/turn/message/tool-result.d.ts +8 -0
  144. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  145. package/session/context/turn/message/tool-result.js +1 -0
  146. package/session/context/turn/turn.d.ts +12 -0
  147. package/session/context/turn/turn.d.ts.map +1 -0
  148. package/session/context/turn/turn.js +2 -0
  149. package/session/conversation.d.ts +64 -0
  150. package/session/conversation.d.ts.map +1 -0
  151. package/session/conversation.js +123 -0
  152. package/session/goal/goal.d.ts +10 -0
  153. package/session/goal/goal.d.ts.map +1 -0
  154. package/session/goal/goal.js +1 -0
  155. package/session/goal/task.d.ts +16 -0
  156. package/session/goal/task.d.ts.map +1 -0
  157. package/session/goal/task.js +3 -0
  158. package/session/session.d.ts +99 -0
  159. package/session/session.d.ts.map +1 -0
  160. package/session/session.js +1 -0
  161. package/src/jobs/check.ts +7 -4
  162. package/src/jobs/context.ts +24 -82
  163. package/src/jobs/permissions/decide-permission.ts +10 -8
  164. package/src/jobs/permissions/find-project-scope.ts +8 -14
  165. package/src/jobs/permissions/match-target.ts +3 -3
  166. package/src/jobs/permissions.ts +8 -6
  167. package/src/jobs/program.ts +7 -3
  168. package/src/jobs/prompt.ts +21 -24
  169. package/src/jobs/schedule.ts +14 -14
  170. package/src/jobs/slash-command.ts +20 -14
  171. package/src/jobs/status-line.ts +9 -21
  172. package/src/jobs/tool.ts +17 -11
  173. package/src/mod.ts +209 -97
  174. package/src/options.ts +22 -22
  175. package/src/records.ts +35 -23
  176. package/src/register.ts +17 -5
  177. package/src/runtime/announce.ts +74 -0
  178. package/src/runtime/bound.ts +12 -0
  179. package/src/runtime/claude.ts +4 -2
  180. package/src/runtime/conversations.ts +302 -0
  181. package/src/runtime/deadline.ts +2 -2
  182. package/src/runtime/dependencies.ts +12 -0
  183. package/src/runtime/engine.ts +22 -0
  184. package/src/runtime/grants.ts +66 -24
  185. package/src/runtime/hooks.ts +19 -10
  186. package/src/runtime/installer.ts +10 -9
  187. package/src/runtime/lifecycle.ts +312 -180
  188. package/src/runtime/metadata.ts +33 -11
  189. package/src/runtime/options.ts +19 -4
  190. package/src/runtime/programs.ts +40 -1
  191. package/src/runtime/router.ts +11 -4
  192. package/src/runtime/sessions.ts +242 -0
  193. package/src/runtime/state.ts +50 -14
  194. package/src/runtime/toaster.ts +218 -0
  195. package/src/runtime/ui.ts +42 -139
  196. package/src/session/context/context.ts +12 -0
  197. package/src/session/context/turn/message/document.ts +8 -0
  198. package/src/session/context/turn/message/message.ts +165 -0
  199. package/src/session/context/turn/message/note.ts +9 -0
  200. package/src/session/context/turn/message/notice.ts +7 -0
  201. package/src/session/context/turn/message/prompt.ts +9 -0
  202. package/src/session/context/turn/message/reply.ts +7 -0
  203. package/src/session/context/turn/message/sender.ts +38 -0
  204. package/src/session/context/turn/message/skill.ts +8 -0
  205. package/src/session/context/turn/message/tool-call.ts +10 -0
  206. package/src/session/context/turn/message/tool-result.ts +9 -0
  207. package/src/session/context/turn/turn.ts +14 -0
  208. package/src/session/conversation.ts +169 -0
  209. package/src/session/goal/goal.ts +9 -0
  210. package/src/session/goal/task.ts +19 -0
  211. package/src/session/session.ts +94 -0
  212. package/src/testing/fake-claude.ts +22 -16
  213. package/src/testing/fake-elements.ts +3 -2
  214. package/src/testing/fake-files.ts +1 -1
  215. package/src/testing/fake-sessions.ts +203 -0
  216. package/src/testing.ts +112 -33
  217. package/src/ui/components.ts +3 -3
  218. package/src/ui/elements.ts +22 -8
  219. package/src/ui/markdown.ts +22 -26
  220. package/src/ui/places.ts +49 -0
  221. package/src/utils/call-effects.ts +3 -3
  222. package/src/utils/changelog.ts +32 -0
  223. package/src/utils/matcher.ts +37 -0
  224. package/src/utils/metadata.ts +17 -15
  225. package/src/utils/parse-shell.ts +9 -8
  226. package/src/utils/paths.ts +15 -1
  227. package/src/utils/text.ts +4 -0
  228. package/testing/fake-claude.d.ts +4 -1
  229. package/testing/fake-claude.d.ts.map +1 -1
  230. package/testing/fake-claude.js +18 -11
  231. package/testing/fake-elements.js +4 -2
  232. package/testing/fake-files.js +1 -1
  233. package/testing/fake-sessions.d.ts +74 -0
  234. package/testing/fake-sessions.d.ts.map +1 -0
  235. package/testing/fake-sessions.js +170 -0
  236. package/testing.d.ts +33 -13
  237. package/testing.d.ts.map +1 -1
  238. package/testing.js +92 -21
  239. package/ui/components.d.ts +2 -3
  240. package/ui/components.d.ts.map +1 -1
  241. package/ui/components.js +3 -3
  242. package/ui/elements.d.ts.map +1 -1
  243. package/ui/elements.js +19 -9
  244. package/ui/markdown.d.ts +35 -33
  245. package/ui/markdown.d.ts.map +1 -1
  246. package/ui/markdown.js +14 -16
  247. package/ui/places.d.ts +43 -0
  248. package/ui/places.d.ts.map +1 -0
  249. package/ui/places.js +31 -0
  250. package/utils/call-effects.d.ts +1 -1
  251. package/utils/call-effects.d.ts.map +1 -1
  252. package/utils/call-effects.js +2 -2
  253. package/utils/changelog.d.ts +9 -0
  254. package/utils/changelog.d.ts.map +1 -0
  255. package/utils/changelog.js +29 -0
  256. package/utils/matcher.d.ts +13 -0
  257. package/utils/matcher.d.ts.map +1 -0
  258. package/utils/matcher.js +32 -0
  259. package/utils/metadata.d.ts +3 -3
  260. package/utils/metadata.js +18 -14
  261. package/utils/parse-shell.js +9 -7
  262. package/utils/paths.d.ts +2 -0
  263. package/utils/paths.d.ts.map +1 -1
  264. package/utils/paths.js +13 -1
  265. package/utils/text.d.ts +1 -0
  266. package/utils/text.d.ts.map +1 -1
  267. package/utils/text.js +3 -0
  268. package/src/ui/slots.ts +0 -36
  269. package/ui/slots.d.ts +0 -208
  270. package/ui/slots.d.ts.map +0 -1
  271. package/ui/slots.js +0 -19
@@ -1,6 +1,6 @@
1
1
  # Permissions
2
2
 
3
- A mod declares what it does beyond drawing and answering hooks: the hosts it connects to, the programs it runs, the files it changes outside its project, and whether it reads the conversation, adds text Claude reads, asks a model, starts agents, uses Claude's tools, changes Claude Code's settings, or approves tool calls. The person sees each permission as one sentence when they install the mod, turns each one on or off in `/mods`, and cmod checks every call and every answer against what they granted.
3
+ A mod declares what it does beyond drawing and answering hooks: the hosts it connects to, the programs it runs, the files it changes outside its project, and whether it reads the conversation, adds text Claude reads, asks a model, starts tasks, uses Claude's tools, changes Claude Code's settings, or approves tool calls. The person sees each permission as one sentence when they install the mod, turns each one on or off in `/mods`, and cmod checks every call and every answer against what they granted.
4
4
 
5
5
  Permissions bind every mod built on `@cmodjs/core`, and the plugin directory reads them from `package.json`. They are a contract, not a sandbox: a Claude Code plugin can skip cmod and call Claude Code directly.
6
6
 
@@ -16,18 +16,18 @@ They sit in the `package.json` `cmod` key, beside `install` and `keys`, because
16
16
 
17
17
  | Permission | The person reads | cmod checks |
18
18
  | --- | --- | --- |
19
- | `"network": ["api.github.com"]` | Connect to api.github.com | `mod.http.fetch` to that host. A path starting with `/` or `~/` names a socket, such as `"~/.docker/run/docker.sock"` |
20
- | `"run": ["gh"]`, or `"run": "*"` | Run gh on your computer | `mod.process.run`, `mod.process.spawn`, and the `program` and `check` jobs. A program matches by its file name, so `"mermaid-ascii"` covers `${mod.dataFolder}/bin/mermaid-ascii` and the copy in cmod's programs folder |
21
- | `"files": ["~/.zshrc"]` | Change ~/.zshrc | `mod.fs.write` and `mod.metadata.update` outside the project and the mod's data folder. A folder covers every file below it |
22
- | `"conversation": true` | Read this conversation | `mod.session.messages`, and reading Claude Code's transcript files |
23
- | `"prompt": true` | Add text Claude reads and start turns | `mod.session.append`, `mod.session.submit`, the `prompt` and `schedule` jobs, and the hook answers listed below |
24
- | `"model": true` | Ask a model, which uses your plan | `mod.model.complete` |
25
- | `"agents": true` | Start agents | `mod.agent.spawn` |
26
- | `"tools": true` | Use and change Claude's tool calls | `mod.claude.tool.call`, and a hook that changes a tool call's input |
27
- | `"config": true` | Change your Claude Code settings | writes to `.claude/settings.json`, `.claude/settings.local.json`, and `.mcp.json` |
19
+ | `"network": ["api.github.com"]` | Connect to api.github.com | `http.fetch` to that host. A path starting with `/` or `~/` names a socket, such as `"~/.docker/run/docker.sock"`. The address of the mod's own running `program` job needs none |
20
+ | `"run": ["gh"]`, or `"run": "*"` | Run gh on your computer | `process.run`, `process.spawn`, and the `program` and `check` jobs. A program matches by its file name, so `"mermaid-ascii"` covers `${mod.dataFolder}/bin/mermaid-ascii` and the copy in cmod's programs folder |
21
+ | `"files": ["~/.zshrc"]` | Change ~/.zshrc | `fs.write` and `metadata.update` outside the project and the mod's data folder. A folder covers every file below it |
22
+ | `"conversation": true` | Read your Claude Code conversations | Reading `sessions`: their messages, turns, contexts, goals, and tasks, `sessions.list`, `sessions.search`, and a session's `on`. Also reading Claude Code's transcript files |
23
+ | `"prompt": true` | Add text Claude reads and start turns | A session's `send`, the `prompt` and `schedule` jobs, and the hook answers listed below |
24
+ | `"model": true` | Ask a model, which uses your plan | `model.complete` |
25
+ | `"tasks": true` | Start tasks that work beside Claude | `sessions.current.startTask` |
26
+ | `"tools": true` | Use and change Claude's tool calls | A listener or a job that changes a tool call's input |
27
+ | `"config": ["cleanupPeriodDays"]` | Change cleanupPeriodDays in your Claude Code settings | `settings.user.update`, `settings.project.update`, and `settings.local.update` of that setting. `mcpServers` covers the project's `.mcp.json` |
28
28
  | `"approve": true` | Approve Claude's tool calls for you | an allow or an ask from the mod's permission rules or hooks, and a `retry`. A deny needs no permission |
29
29
 
30
- Reading files, drawing panes and slot renders, toasts, slash commands, tools of the mod's own, and hooks need no permission.
30
+ Reading files, drawing panes and renders, toasts, slash commands, tools of the mod's own, and listeners need no permission. Writing a Claude Code settings file with `fs.write` is refused with every permission, and names the `settings` call to use instead ([mod.md](mod.md#settings-claude-code-s-settings-files)).
31
31
 
32
32
  `cmod check` adds `"approve": true` to `package.json` when `hooks/register.ts` calls `registerPermissionCheck` and the line is missing, and says so.
33
33
 
@@ -46,16 +46,27 @@ The install opens the installer pane in Claude Code and lists every permission i
46
46
  ## Check a permission before a feature uses it
47
47
 
48
48
  ```ts
49
- mod.permissions.has(name: 'network' | 'run' | 'files', value: string): boolean
50
- mod.permissions.has(name: 'conversation' | 'prompt' | 'model' | 'agents' | 'tools' | 'config' | 'approve'): boolean
49
+ type Permissions = {
50
+ readonly conversation: boolean
51
+ readonly prompt: boolean
52
+ readonly model: boolean
53
+ readonly tasks: boolean
54
+ readonly tools: boolean
55
+ readonly approve: boolean
56
+ readonly network: { allows(host: string): boolean }
57
+ readonly run: { allows(program: string): boolean }
58
+ readonly files: { allows(path: string): boolean }
59
+ readonly config: { allows(key: string): boolean }
60
+ }
51
61
  ```
52
62
 
53
- `has` says whether the person has the permission turned on, so a mod hides a feature instead of failing it:
63
+ `mod.permissions` says whether the person has each permission turned on, so a mod hides a feature instead of failing it. A flag is `true` while it is granted, and `allows` answers for one host, program, path, or settings key:
54
64
 
55
65
  ```ts
56
- if (mod.permissions.has('model')) {
57
- const verdict = await mod.model.complete({ prompt: 'Is this reply a waste of time?' })
66
+ if (mod.permissions.model) {
67
+ const verdict = await model.complete({ model: 'haiku', prompt: 'Is this reply a waste of time?' })
58
68
  }
69
+ if (mod.permissions.run.allows('gh')) mod.every(60_000, refreshRuns)
59
70
  ```
60
71
 
61
72
  ## When a call has no grant
@@ -0,0 +1,248 @@
1
+ # Sessions
2
+
3
+ `sessions` reads and follows Claude Code's conversations: the one the mod runs in, every other one Claude Code saved, and a search across them. A mod never parses a transcript.
4
+
5
+ ```ts
6
+ import { Prompt, sessions } from '../node_modules/@cmodjs/core/mod.js'
7
+
8
+ const prompts = (await sessions.current.messages()).filter((message) => message.type === Prompt)
9
+ ```
10
+
11
+ Reading a conversation needs the `conversation` permission, sending to one needs `prompt`, and starting a task needs `tasks` ([permissions.md](permissions.md)).
12
+
13
+ ## The shape of a conversation
14
+
15
+ A session is one conversation. It runs as a series of contexts: a compaction or `/compact` ends one context and starts the next with a summary of the last. Each context holds turns: a turn starts at a prompt and ends when Claude stops. Each turn holds the messages Claude and the people and programs around it exchanged. The session's goal is what the conversation is for, and its tasks are the subagents Claude or a mod started to work beside it.
16
+
17
+ ```
18
+ Session
19
+ ├── Goal what the session is for
20
+ │ └── Task a subagent working beside Claude
21
+ │ ├── Message its own messages
22
+ │ └── Task a task it started
23
+ └── Context from the start, or a compaction, to the next compaction
24
+ └── Turn from a prompt to the end of Claude's answer
25
+ └── Message a prompt, a reply, a tool call, and the rest
26
+ ```
27
+
28
+ ## sessions
29
+
30
+ ```ts
31
+ type Sessions = {
32
+ readonly current: Session
33
+ list(options?: { running?: boolean; since?: string; everyProject?: boolean }): Promise<Session[]>
34
+ search(text: string, options?: { running?: boolean; since?: string; everyProject?: boolean; limit?: number; before?: Message }): Promise<Message[]>
35
+ }
36
+ ```
37
+
38
+ - `current` is the session the mod runs in. It is there from `setup` on, and follows `/clear`, `/resume`, a fork, `/cd`, and a `cd` in a Bash or PowerShell call.
39
+ - `list` lists the project's sessions, the most recent first. `everyProject` lists every project's, `running` keeps the sessions still open in a Claude Code window, and `since` keeps those active since a time, written as `30m`, `6h`, `2d`, `1w`, or a date such as `2026-10-01`.
40
+ - `search` answers the newest 50 messages whose text contains `text`, any case, in the project's sessions or, with `everyProject`, every project's. `text` is at least 3 characters. `limit` changes the 50, and `before` takes the last message of a page to answer the page after it. A tool call is searched by its name and the first 1,024 characters of each input value, a document by its path and text, and a tool result is not searched.
41
+
42
+ ```ts
43
+ const earlier = await sessions.search('deploy job', { since: '1w' })
44
+ const last = earlier.at(-1)
45
+ const older = last === undefined ? [] : await sessions.search('deploy job', { since: '1w', before: last })
46
+ ```
47
+
48
+ While a mod holds the `conversation` permission, the cmod plugin indexes every conversation in the background at each session's start, so `search` waits only for what changed since. The `cmod history` command answers the same questions in a terminal ([commands.md](commands.md#cmod-history)).
49
+
50
+ ## Session
51
+
52
+ ```ts
53
+ type Session = {
54
+ readonly id: string
55
+ readonly title: string
56
+ readonly project: string
57
+ readonly cwd: string
58
+ readonly model: string
59
+ readonly startedAt: string
60
+ readonly lastAt: string
61
+ readonly forkedFrom?: string
62
+ readonly isRunning: boolean
63
+ usage(): Promise<Usage>
64
+ on(event, listener): Disposable
65
+ on(event, matcher, listener): Disposable
66
+ send(message: { type: Prompt | Note; text: string; as?: Person | Session }): Promise<void>
67
+ startTask(task: { prompt: string; agentType?: string; model?: string; name?: string; cwd?: string }): Promise<Task>
68
+ messages(options?: { after?: string; last?: number }): Promise<Message[]>
69
+ turns(options?: { last?: number }): Promise<Turn[]>
70
+ contexts(): Promise<Context[]>
71
+ goal(): Promise<Goal>
72
+ }
73
+ ```
74
+
75
+ - `id` is Claude Code's session id. `title` is the name the person gave it with `/rename`, or the first line of its first prompt. A resumed or forked session with no name, and a session the mod started in after its first prompt, has an empty `sessions.current.title` until `sessions.list()` reads it.
76
+ - `project` is the project the session works in, and `cwd` is its working folder. For the current session both follow `/cd`, and `cwd` follows a `cd` in a Bash or PowerShell call.
77
+ - `model` is the model the session runs on. `startedAt` and `lastAt` are ISO times.
78
+ - `forkedFrom` names the session a `/branch` or a fork copied it from.
79
+ - `isRunning` is true while the session is open in a Claude Code window.
80
+ - `usage` answers the current session's model, its context window's fill, and its cost so far, as the status line shows them: `{ model, context: { tokens?, window, percent? }, cost?: { usd } }`.
81
+
82
+ ### Read a conversation
83
+
84
+ - `messages` answers the session's messages in order, those of its tasks left out. `last` keeps that many from the end, and `after` starts after a message's `id`.
85
+ - `turns` answers its turns in order, `last` keeping that many from the end. A turn's `messages()` answers its own.
86
+ - `contexts` answers its contexts in order. A context's `turns()` answers its own.
87
+ - `goal` answers its goal. The goal's `tasks()` answers the tasks Claude or a mod started, and each task's `messages()` and `tasks()` answer its own.
88
+
89
+ ```ts
90
+ const [last] = await sessions.current.turns({ last: 1 })
91
+ const calls = (await last?.messages() ?? []).filter((message) => message.type === ToolCall)
92
+ ```
93
+
94
+ ### Listen to a conversation
95
+
96
+ ```ts
97
+ session.on(event: SessionEvent, listener: (payload) => unknown): Disposable
98
+ session.on(event: SessionEvent, matcher: Matcher<payload>, listener: (payload) => unknown): Disposable
99
+ ```
100
+
101
+ `on` runs `listener` for each new message, turn, context, task, or goal change of that session, as it happens. The current session's arrive as Claude Code writes them, and another running session's within a moment.
102
+
103
+ | Event | The listener gets |
104
+ | --- | --- |
105
+ | `Message` | Every new message. |
106
+ | `Prompt`, `Note`, `Reply`, `ToolCall`, `ToolResult`, `Skill`, `Document`, `Notice` | Each new message of that type. |
107
+ | `TurnStart`, `TurnEnd` | The `Turn` that started or ended. |
108
+ | `ContextStart`, `ContextEnd` | The `Context` that started or ended. |
109
+ | `TaskStart`, `TaskEnd` | The `Task` that started or ended. |
110
+ | `GoalChange` | The `Goal` with its new text. |
111
+
112
+ A matcher keeps the payloads it matches. It is a partial copy of the payload: a key it names must match, and a key it leaves out matches anything. A string matches the same string, a `RegExp` matches the text it tests true on, and a list matches any one of its items.
113
+
114
+ ```ts
115
+ import { Reply, sessions, ToolCall } from '../node_modules/@cmodjs/core/mod.js'
116
+
117
+ sessions.current.on(ToolCall, { name: /^(Edit|Write)$/ }, (call) => void (mod.state.session.edits += 1))
118
+ sessions.current.on(Reply, (reply) => mod.log(`Claude said ${reply.text.length} characters`))
119
+ ```
120
+
121
+ A listener that throws shows the error toast `The <event> listener failed`. `dispose()` on the `Disposable` stops the listener.
122
+
123
+ ### Send to a conversation
124
+
125
+ `send` adds a message to the session, and needs the `prompt` permission.
126
+
127
+ - A `Note` to the current session is text Claude reads on its next call to the model and the person does not see. It starts no turn. It rejects with `<mod>: Claude Code refused the note: <reason>` when a plugin refuses it.
128
+ - A `Prompt` to the current session is the 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 frames it as `The <mod> plugin sent a message:`. With `as: Person`, Claude reads it bare, as the person's own words, while every hook still sees that the mod sent it. It rejects with `<mod>: Claude Code dropped the prompt: <reason>` when a plugin drops it.
129
+ - To another running session, cmod delivers the message, and that session's cmod adds it the same way. `as: Session` sends it as a message from this session instead, which Claude Code shows as one session writing to another.
130
+ - A session that has ended takes no messages, and `send` rejects with `<mod>: session <id> has ended, so it takes no messages.`
131
+
132
+ 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)).
133
+
134
+ ```ts
135
+ mod.every(60_000, async () => {
136
+ const { stdout } = await process.run(['gh', 'run', 'list', '--limit', '1', '--json', 'conclusion'])
137
+ if (JSON.parse(stdout)[0]?.conclusion !== 'failure') return
138
+ await sessions.current.send({ type: Note, text: 'CI failed on the last push.' })
139
+ await sessions.current.send({ type: Prompt, text: 'Find the cause of the CI failure and fix it.' })
140
+ })
141
+ ```
142
+
143
+ ### Start a task
144
+
145
+ `startTask` starts a subagent in the current session, the way Claude's Agent tool starts one, and resolves the `Task` once it started. It does not wait for the task to finish. `agentType` names an agent type, such as `explorer`, `name` titles the task, and `model` and `cwd` change what it runs on and where. It rejects with `<mod>: Claude Code refused the task: <reason>` when a hook refuses it, and on any session other than the current one.
146
+
147
+ The task's answer arrives once it finishes, on a `TaskEnd` listener or the mod's `SubagentStop` hook, whose `agent_id` is the task's `id`:
148
+
149
+ ```ts
150
+ const answers = new Map<string, (answer: string) => void>()
151
+
152
+ mod.on(SubagentStop, (input) => answers.get(input.agent_id)?.(input.last_assistant_message ?? ''))
153
+
154
+ mod.on(UserPromptSubmit, { prompt: /^\/summarize/ }, async () => {
155
+ const task = await sessions.current.startTask({ prompt: 'Summarize README.md in one line.', agentType: 'Explore', name: 'Summarize the README' })
156
+ const summary = await new Promise<string>((resolve) => answers.set(task.id, resolve))
157
+ return { hookSpecificOutput: { hookEventName: UserPromptSubmit, additionalContext: summary } }
158
+ })
159
+ ```
160
+
161
+ ## Message
162
+
163
+ Every message has these fields, and the fields of its type:
164
+
165
+ ```ts
166
+ type MessageOf<Type> = {
167
+ readonly id: string
168
+ readonly type: Type
169
+ readonly at: string
170
+ readonly session: string
171
+ readonly context: number
172
+ readonly turn?: string
173
+ readonly task?: string
174
+ }
175
+ ```
176
+
177
+ - `id` names the transcript row the message is in. When one row holds several messages, such as a reply with its tool calls, each `id` ends in `:<n>`. `mod.ui.scroll({ to: { requestId: message.id.split(':')[0] } })` scrolls to the message's row ([ui.md](ui.md#scrolling)).
178
+ - `at` is its ISO time. `session` is the session's id, and `context` the number of the context it is in, from 1.
179
+ - `turn` is the `id` of the prompt that opened its turn. A message before the first prompt, and a task's message, has none.
180
+ - `task` is the id of the task whose message it is. A message of the session itself has none.
181
+
182
+ | Type | Constant | Its own fields |
183
+ | --- | --- | --- |
184
+ | `'prompt'` | `Prompt` | `text`, `from`: the prompt that opens a turn. A slash command reads as the command typed, such as `/review 12`. |
185
+ | `'note'` | `Note` | `text`, `from`: text Claude reads that is not a prompt, such as a mod's note or a task's notification. |
186
+ | `'reply'` | `Reply` | `text`: what Claude said. |
187
+ | `'toolCall'` | `ToolCall` | `callId`, `name`, `input`, `cwd`: a tool Claude called, and the folder it called it in. |
188
+ | `'toolResult'` | `ToolResult` | `callId`, `text`, `isError`: what the tool answered. |
189
+ | `'skill'` | `Skill` | `name`, `text`: a Skill Claude Code loaded. |
190
+ | `'document'` | `Document` | `path`, `text`: a `CLAUDE.md` file Claude Code read for a folder. |
191
+ | `'notice'` | `Notice` | `text`: a line Claude Code added, such as a model switch. |
192
+
193
+ `Message` is any of these, and `message.type` tells them apart:
194
+
195
+ ```ts
196
+ for (const message of await sessions.current.messages()) {
197
+ if (message.type === ToolCall) console.log(message.name, message.input)
198
+ }
199
+ ```
200
+
201
+ ### Sender
202
+
203
+ A prompt's and a note's `from` says who sent it:
204
+
205
+ ```ts
206
+ type Sender = { readonly type: SenderType; readonly name?: string; readonly as?: 'person' }
207
+ ```
208
+
209
+ | `type` | Who sent it |
210
+ | --- | --- |
211
+ | `Person` (`'person'`) | The person, at the keyboard or from a phone. |
212
+ | `'mod'` | A mod, named by `name`. `as: Person` when it sent the prompt as the person's. |
213
+ | `'session'` | Another session. |
214
+ | `'task'` | A task, reporting back. |
215
+ | `Schedule` (`'schedule'`) | A scheduled prompt. |
216
+ | `Channel` (`'channel'`) | A channel, such as Slack, named by `name`. |
217
+ | `Sdk` (`'sdk'`) | A program driving Claude Code through the SDK. |
218
+
219
+ ## Turn, Context, Goal, and Task
220
+
221
+ ```ts
222
+ type Turn = { readonly prompt: Prompt; readonly context: number; readonly openedAt: string; readonly closedAt?: string; messages(): Promise<Message[]> }
223
+
224
+ type Context = { readonly number: number; readonly openedAt: string; readonly closedAt?: string; turns(): Promise<Turn[]> }
225
+
226
+ type Goal = { readonly text: string; update(change: { text: string }): Promise<void>; tasks(): Promise<Task[]> }
227
+
228
+ type Task = {
229
+ readonly id: string
230
+ readonly title: string
231
+ readonly agentType: string
232
+ readonly model?: string
233
+ readonly openedBy: string
234
+ readonly openedAt: string
235
+ readonly closedAt?: string
236
+ messages(): Promise<Message[]>
237
+ tasks(): Promise<Task[]>
238
+ }
239
+ ```
240
+
241
+ - A turn's `closedAt` is set once Claude stops. A turn still running has none.
242
+ - A context's `number` counts from 1. Its `closedAt` is set at the compaction that ended it.
243
+ - A goal's `text` starts as the session's first prompt. `update` replaces it, and a `GoalChange` listener on the current session hears the new text.
244
+ - A task's `openedBy` is the `callId` of the Agent call that started it, and empty for a task a mod started. Its `closedAt` is set once it answered.
245
+
246
+ ## Test it
247
+
248
+ `testMod` fakes the conversation: `tested.add`, `tested.endTurn`, `tested.compact`, and `tested.startTask` build the current session, and the `sessions` option gives the earlier ones ([testing.md](testing.md#build-a-conversation)).
package/docs/state.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # State
2
2
 
3
- `mod.state` holds the mod's values. Claude Mod Manager (cmod) saves them, redraws the mod's panes and slot renders when one changes, and lets a person change the starting values without touching the mod's code.
3
+ `mod.state` holds the mod's values. Claude Mod Manager (cmod) saves them, redraws the mod's panes and renders when one changes, and lets a person change the starting values without touching the mod's code.
4
4
 
5
5
  ## Declare the state
6
6
 
@@ -9,11 +9,15 @@
9
9
  | Group | How long a value lasts | Saved |
10
10
  | --- | --- | --- |
11
11
  | `memory` | Until `/clear`, `--resume`, `/branch`, or a reload of Claude Code or its plugins. A compaction keeps it. | No |
12
- | `session` | This conversation. It comes back on `--resume`, starts over on `/clear`, and `/branch` copies it. | Yes |
12
+ | `session` | This conversation. It comes back on `--resume`, starts over on `/clear`, and a fork of the conversation starts with it. | Yes |
13
13
  | `project` | This project, in every conversation. | Yes |
14
14
  | `global` | Every project. | Yes |
15
15
 
16
- cmod keeps each `session` value for the 20 sessions that wrote it last, and each `project` value for the 20 projects that wrote it last. A session or project past those 20 starts that value over from its starting value.
16
+ cmod keeps each `session` value as long as Claude Code keeps the session's transcript, so a session comes back with its values for as long as `--resume` can find it.
17
+
18
+ A fork of the conversation, by `/branch`, `claude --resume <id> --fork-session`, or a move to the agents view, starts with the `session` values of the conversation it came from. Outside `/branch`, Claude Code does not say which conversation a fork came from, so the cmod plugin matches the fork's copied conversation to the one it came from, which needs its "Read your Claude Code conversations" permission. A fork starts with the starting values when that permission is off, when it was taken while the conversation it came from was still answering, or in a `-p` run.
19
+
20
+ cmod keeps each `project` value for the 20 projects that wrote it last, and a project past those 20 starts that value over from its starting value.
17
21
 
18
22
  Put values the mod works out again on every prompt, such as a git status, in `memory`.
19
23
 
@@ -38,14 +42,14 @@ export const initialState: TasksState = {
38
42
  Then pass it to `defineMod`:
39
43
 
40
44
  ```ts
41
- import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
45
+ import { defineMod, UserPromptSubmit } from '../node_modules/@cmodjs/core/mod.js'
42
46
  import { initialState } from './state.js'
43
47
 
44
48
  export const tasks = defineMod({
45
49
  name: 'tasks',
46
50
  state: initialState,
47
51
  setup(mod) {
48
- mod.on('UserPromptSubmit', () => {
52
+ mod.on(UserPromptSubmit, () => {
49
53
  mod.state.session.prompts += 1
50
54
  })
51
55
  },
@@ -62,13 +66,13 @@ Assign to a key. cmod saves the new value and redraws the mod.
62
66
  - An object or an array in the state is frozen. Changing it in place throws `<mod>: mod.state.<path> cannot change in place.` Assign a new value instead:
63
67
 
64
68
  ```ts
65
- import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
69
+ import { defineMod, PostToolUse } from '../node_modules/@cmodjs/core/mod.js'
66
70
 
67
71
  export const reads = defineMod({
68
72
  name: 'reads',
69
73
  state: { memory: { readFiles: [] as readonly string[] } },
70
74
  setup(mod) {
71
- mod.on('PostToolUse', (input) => {
75
+ mod.on(PostToolUse, (input) => {
72
76
  mod.state.memory.readFiles = [...mod.state.memory.readFiles, ...input.files.read]
73
77
  })
74
78
  },
package/docs/testing.md CHANGED
@@ -34,6 +34,7 @@ type TestOptions<State, Options> = {
34
34
  readonly dependencies?: { [Name in keyof CmodDependencies]?: CmodDependencies[Name] }
35
35
  readonly files?: Readonly<Record<string, string>>
36
36
  readonly links?: Readonly<Record<string, string>>
37
+ readonly sessions?: readonly FakeSession[]
37
38
  }
38
39
  ```
39
40
 
@@ -43,15 +44,16 @@ type TestOptions<State, Options> = {
43
44
  | `options` | The option values Claude Code passes, as a person set them ([options.md](options.md)) | none: each option gets its default |
44
45
  | `permissions` | Exactly the permissions the person granted, such as `['network:api.github.com', 'model']` ([permissions.md](permissions.md)) | every permission the mod's `package.json` declares |
45
46
  | `scope` | `'project'` makes the mod a project plugin, in `<projectRoot>/.claude/skills/<name>` | `'user'` |
46
- | `projectRoot` | `mod.projectRoot` | `/test/plugins/<name>`, or `/test/project` for a project plugin |
47
- | `cwd` | `mod.cwd` | `projectRoot` |
47
+ | `projectRoot` | `sessions.current.project` | `/test/plugins/<name>`, or `/test/project` for a project plugin |
48
+ | `cwd` | `sessions.current.cwd` | `projectRoot` |
48
49
  | `dependencies` | The methods other mods answer with | none: every call fails with `<name> is not installed. Run cmod install <name>.` |
49
50
  | `files` | The fake file system, absolute path to text | no files |
50
51
  | `links` | Symbolic links in the fake file system, absolute link path to its target | no links |
52
+ | `sessions` | The earlier conversations `sessions.list` and `sessions.search` read, each `{ id, messages?, goal?, … }` ([Build a conversation](#build-a-conversation)) | none: only the current session, `test-session` |
51
53
 
52
54
  A project plugin finds its repository through a `.git` entry at `projectRoot`, so a test of one puts it in `files`, such as `files: { '/test/project/.git': '' }`. Without it the project plugin has no repository, and its rules match calls in every folder, as a user mod's do.
53
55
 
54
- The fake session's id is `test-session`, its model is `test-model`, and its home is `/test/home`. So `mod.dataFolder` is `/test/home/.local/share/cmod/data/<name>`. The mod is installed and draws on a terminal 80 columns wide.
56
+ The fake session's id is `test-session`, its model is `test-model`, and its home is `/test/home`. So `mod.dataFolder` is `/test/home/.local/share/cmod/data/<name>`, and `mod.folder` is `/test/plugins/<name>`. The mod is installed and draws on a terminal 80 columns wide.
55
57
 
56
58
  ## TestedMod
57
59
 
@@ -60,12 +62,16 @@ type TestedMod<State> = {
60
62
  start(): Promise<void>
61
63
  fire(event, input, below?): Promise<EventResult>
62
64
  settle(): Promise<void>
65
+ add(message: MessageDraft, options?: { task?: string }): Promise<Message[]>
66
+ endTurn(): Promise<void>
67
+ compact(): Promise<void>
68
+ startTask(task: { title: string; agentType?: string; model?: string; openedBy?: string }): Promise<string>
63
69
  lines(paneId: string): Promise<string[]>
64
- lines(slot, props, requestId?): Promise<string[]>
65
- type(line: string): Promise<Reply>
70
+ lines(place, props, requestId?): Promise<string[]>
71
+ type(line: string): Promise<CommandReply>
66
72
  callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
67
73
  press(paneId: string, key: string, link?: string): Promise<void>
68
- input(paneId: string, key: string, text: string, kind?: 'submit' | 'change'): Promise<void>
74
+ input(paneId: string, key: string, text: string, type?: 'submit' | 'change'): Promise<void>
69
75
  select(paneId: string, key: string, value: string): Promise<void>
70
76
  moveTo(projectRoot: string, cwd?: string): Promise<void>
71
77
  readonly state: Readonly<State>
@@ -82,13 +88,14 @@ Starts the mod: loads its state, runs `setup`, and adds its hooks, panes, and jo
82
88
  ### fire
83
89
 
84
90
  ```ts
85
- fire<E extends ModEvent>(event: E, input: TestInput<E>, below?): Promise<EventResult<`classic.${E}`>>
91
+ fire<H extends Hook>(hook: H, input: TestInput<H>, below?): Promise<EventResult<`classic.${H}`>>
86
92
  fire<N extends RoutedEvent>(event: N, input: Args<N>, below?): Promise<EventResult<N>>
87
93
  ```
88
94
 
89
- - `fire('UserPromptSubmit', { prompt: 'hello' })` runs the mod's hooks on a `ModEvent`. `TestInput<E>` is the event's input without the fields `fire` fills: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, and a new `tool_use_id` for each tool event.
95
+ - `fire(UserPromptSubmit, { prompt: 'hello' })` runs the mod's listeners on a hook. `TestInput<H>` is the hook's input without the fields `fire` fills: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, and a new `tool_use_id` for each tool event.
96
+ - A `SessionStart` that names no `transcript_path` also writes its transcript as an empty file in the test's files, so the session's `session` values last as they do in Claude Code ([state.md](state.md)).
90
97
  - For a tool event, `files` is worked out from the call and the test's `files`, as in Claude Code.
91
- - `fire('PreToolUse', …)` runs the call's `tool.call`, and its `tool.check` inside it, as Claude Code does. `testMod` runs a mod as if `register.ts` calls `registerPermissionCheck`, so it returns `allow: true` or `ask` with the reason when the mod's hooks or `permissions` job decided the permission. A `PreToolUse` with `agent_id` and `agent_type`, such as `fire('PreToolUse', { tool_name: 'Read', tool_input: { file_path: 'a.ts' }, agent_id: 'agent-7', agent_type: 'explorer' })`, runs as that subagent's call. It returns `updatedInput` when the hooks rewrote the input, `additionalContext` as a list, and `deny` when a hook or Claude Code refused the call. Its `below` is `{ deny }` to test a call Claude Code refuses, and Claude Code runs the call otherwise.
98
+ - `fire(PreToolUse, …)` runs the call's `tool.call`, and its `tool.check` inside it, as Claude Code does. `testMod` runs a mod as if `register.ts` calls `registerPermissionCheck`, so it returns `allow: true` or `ask` with the reason when the mod's hooks or `permissions` job decided the permission. A `PreToolUse` with `agent_id` and `agent_type`, such as `fire(PreToolUse, { tool_name: 'Read', tool_input: { file_path: 'a.ts' }, agent_id: 'agent-7', agent_type: 'explorer' })`, runs as that subagent's call. It returns `updatedInput` when the hooks rewrote the input, `additionalContext` as a list, and `deny` when a hook or Claude Code refused the call. Its `below` is `{ deny }` to test a call Claude Code refuses, and Claude Code runs the call otherwise.
92
99
  - On every other event, `below` is what the hooks beneath the mod answered. Pass it to test how the mod's answer combines with another. When nothing in the mod answers, `fire` returns `below` as it is.
93
100
  - 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.
94
101
  - It returns the combined answer in Claude Code's own field names: `additionalContext` is a list, and `decision: 'block'` is `block`.
@@ -115,7 +122,7 @@ test('a force push is denied with the reason', async () => {
115
122
 
116
123
  ### Test a prompt with when
117
124
 
118
- A `prompt` with `when` runs on the hook-module event `prompt.submit`, not on `UserPromptSubmit`, so `fire('UserPromptSubmit', …)` never reaches it. `fire('prompt.submit', …)` returns the prompt that entered, with the text the prompt added in its `context`:
125
+ A `prompt` with `when` runs on the hook-module event `prompt.submit`, not on `UserPromptSubmit`, so `fire(UserPromptSubmit, …)` never reaches it. `fire('prompt.submit', …)` returns the prompt that entered, with the text the prompt added in its `context`:
119
126
 
120
127
  - The `prompt.submit` input is `{ text, wait, origin }`. A prompt the person typed has `wait: false` and `origin: { kind: 'composer' }`.
121
128
  - `context` is a list of strings, each `# <name>\n<text>`. The answer has no `context` when no prompt added anything.
@@ -153,26 +160,57 @@ test('a prompt about a release gets the release steps, and another prompt gets n
153
160
 
154
161
  Waits until the work a hook started without awaiting it has run, such as a `void` call in a `Stop` hook. It returns while a fake the test holds open has not answered.
155
162
 
163
+ ### Build a conversation
164
+
165
+ The current session, `test-session`, starts empty. A test builds the conversation the mod reads through `sessions` ([session.md](session.md)), the way Claude Code would write it:
166
+
167
+ - `add(message, { task? })` adds a message and returns it, placed in its context and turn. A `Prompt` opens a new turn. With `task`, the message is that task's.
168
+ - `endTurn()` ends the open turn, as Claude stopping does.
169
+ - `compact()` ends the context and starts the next, as a compaction does.
170
+ - `startTask({ title, agentType?, model?, openedBy? })` starts a task and returns its id.
171
+
172
+ Each one runs the mod's session listeners, as a new message does in Claude Code. A `MessageDraft` is a message without its place: `{ type: Prompt, text, from? }`, `{ type: Note, text, from? }`, `{ type: Reply, text }`, `{ type: ToolCall, name, input?, callId? }`, `{ type: ToolResult, callId, text, isError? }`, `{ type: Skill, name, text }`, `{ type: Document, path, text }`, or `{ type: Notice, text }`.
173
+
174
+ The `sessions` option gives the earlier conversations, each a `FakeSession`: an `id`, its `messages` as drafts, its `goal`, and any `Session` field, such as `title`, `project`, or `isRunning`. A session that names no `project` is in the test's project.
175
+
176
+ ```ts
177
+ import { expect, test } from 'bun:test'
178
+ import { Prompt, Reply } from '../node_modules/@cmodjs/core/mod.js'
179
+ import { testMod } from '../node_modules/@cmodjs/core/testing.js'
180
+ import { recall } from '../src/mod.js'
181
+
182
+ test('/recall finds the earlier session about the deploy', async () => {
183
+ const tested = testMod(recall, { sessions: [{ id: 'older', title: 'Fix the deploy', messages: [{ type: Prompt, text: 'Why did the deploy fail?' }] }] })
184
+ await tested.add({ type: Prompt, text: 'Recall the deploy' })
185
+ await tested.add({ type: Reply, text: 'Looking.' })
186
+ await tested.endTurn()
187
+
188
+ expect(await tested.type('/recall deploy')).toEqual({ text: 'Fix the deploy: Why did the deploy fail?' })
189
+ })
190
+ ```
191
+
192
+ `shown.sent` lists what the mod sent to other sessions. `fakes.agent.spawn` answers `startTask`, such as `async () => ({ agentId: 'agent-7', model: 'test-model' })`.
193
+
156
194
  ### lines
157
195
 
158
196
  - `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.
159
197
  - 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.
160
- - `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.
198
+ - `lines(place, props, requestId?)` draws a place 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 `Reply` with a reply's `text` to test a markdown place render. Draws that pass one `requestId` grow one streamed reply. `AbovePrompt` ends with the toasts shown now, under the mod's own rows.
161
199
 
162
200
  ```ts
163
201
  import { expect, test } from 'bun:test'
164
202
  import type { RenderPropsOf } from 'claude-code'
203
+ import { AbovePrompt, UserPromptSubmit } from '../node_modules/@cmodjs/core/mod.js'
165
204
  import { testMod } from '../node_modules/@cmodjs/core/testing.js'
166
- import { slots } from '../node_modules/@cmodjs/core/ui/slots.js'
167
205
  import { hints } from '../src/mod.js'
168
206
 
169
207
  const band: RenderPropsOf['AbovePrompt'] = { hasSurvey: false, isWorking: false, maxRows: 12, bodyColumns: 75, scroll: { offset: 0, bodyRows: 12 }, view: {} }
170
208
 
171
- test('the band shows the prompt count on its last line', async () => {
209
+ test('the band shows the prompt count', async () => {
172
210
  const tested = testMod(hints)
173
- await tested.fire('UserPromptSubmit', { prompt: 'hello' })
211
+ await tested.fire(UserPromptSubmit, { prompt: 'hello' })
174
212
 
175
- expect((await tested.lines(slots.AbovePrompt, band)).at(-1)).toBe('Prompts: 1')
213
+ expect(await tested.lines(AbovePrompt, band)).toContain('Prompts: 1')
176
214
  })
177
215
  ```
178
216
 
@@ -190,7 +228,7 @@ test('the band shows the prompt count on its last line', async () => {
190
228
 
191
229
  - `press(paneId, key)` presses the `Button` whose `key` or `label` is `key`, and runs its `onPress`.
192
230
  - `press(paneId, key, link)` presses the link `link` in the `Markdown` whose `key` is `key`, and runs its `onLinkPress`.
193
- - `input(paneId, key, text)` types `text` into the `Input` whose `key` is `key` and presses Enter, which runs its `onSubmit`. With `kind` `'change'` it runs `onInput` instead, as one edit does.
231
+ - `input(paneId, key, text)` types `text` into the `Input` whose `key` is `key` and presses Enter, which runs its `onSubmit`. With `type` `'change'` it runs `onInput` instead, as one edit does.
194
232
  - `select(paneId, key, value)` picks the option `value` of the `Select` whose `key` is `key`, and runs its `onSelect`. It rejects an option the `Select` does not have.
195
233
 
196
234
  ```ts
@@ -201,7 +239,7 @@ expect(await tested.lines('history')).toContain('deploy by explorer')
201
239
 
202
240
  ### moveTo
203
241
 
204
- `moveTo(projectRoot, cwd?)` moves the session. A new `projectRoot` runs a `/cd` there, so the mod loads that project's state. A `cwd` then runs a Bash `cd` there. The mod's `CwdChanged` hooks run for each move.
242
+ `moveTo(projectRoot, cwd?)` moves the session. A new `projectRoot` runs a `/cd` there, so the mod loads that project's state. A `cwd` then runs a Bash `cd` there. The mod's `CwdChanged` listeners run for each move, and `sessions.current` follows it.
205
243
 
206
244
  ### state
207
245
 
@@ -235,20 +273,22 @@ Set a fake to answer a call:
235
273
 
236
274
  - A call with no fake throws `<call>(<args>) has no fake answer. Set fakes.<call> on the tested mod.` So set `fakes.process.run` before a test runs a program, and `fakes.ui.ask` before the mod asks a question.
237
275
  - A `program` job is the exception: it catches the `process.spawn` error and retries after 1, 2, 4, 8 and 16 seconds on real timers, so its `ready()` waits past `bun test`'s 5-second timeout. Set `fakes.process.spawn` before a test starts a `program` job.
238
- - `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.
276
+ - `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 the session's working folder, so a mod under test reads `${sessions.current.project}/<file>` rather than a relative path. `fs.list()` with no path rejects.
239
277
  - `ui.open` places every pane by default. A fake that answers `{ isPlaced: false }` holds a pane back.
240
278
  - `ui.scroll` answers `{}` by default, as when the window moved. A fake that answers `{ deny }` refuses the scroll.
241
279
  - `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.
242
280
  - `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.
243
281
  - `model.complete` has no default: set it to the answer the model gives, such as `async () => ({ isAnswered: true, text: 'no', usage })`.
282
+ - `settings.read` answers `{}` by default, as Claude Code does with no `settings.json` files.
244
283
  - `config.list` answers no `/config` rows by default. A `config.list` fake that answers a row with `isLocked: true` locks that option as managed settings do.
245
284
  - `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.
246
- - `files` takes symbolic links too: `testMod(mod, { files, links: { '/work/skills': '/shared/skills' } })` makes `/work/skills` a link to `/shared/skills`, so a test of `mod.fs.find` or `mod.metadata.update` follows it as a real file system does.
285
+ - `files` takes symbolic links too: `testMod(mod, { files, links: { '/work/skills': '/shared/skills' } })` makes `/work/skills` a link to `/shared/skills`, so a test of `fs.find` or `metadata.update` follows it as a real file system does.
247
286
  - `testMod` runs no installer pane: an option with no default and no value fails `start()` with its fix, and the `installer` steps are not shown. Test a step's `render` and `isDone` by calling them with the started `mod`.
248
287
  - The `committer` below runs `git`, so its `package.json` declares `"permissions": { "run": ["git"] }`. A gated call the mod does not declare fails before it reaches a fake ([permissions.md](permissions.md)).
249
288
 
250
289
  ```ts
251
290
  import { expect, test } from 'bun:test'
291
+ import { Stop } from '../node_modules/@cmodjs/core/mod.js'
252
292
  import { testMod } from '../node_modules/@cmodjs/core/testing.js'
253
293
  import { committer } from '../src/mod.js'
254
294
 
@@ -257,7 +297,7 @@ test('a Commit answer commits', async () => {
257
297
  tested.fakes.ui.ask = async () => 'Commit'
258
298
  tested.fakes.process.run = async () => ({ exitCode: 0, stdout: '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false })
259
299
 
260
- await tested.fire('Stop', { stop_hook_active: false })
300
+ await tested.fire(Stop, { stop_hook_active: false })
261
301
 
262
302
  expect(tested.calls.filter(({ call }) => call === 'process.run').map(({ args }) => args[0])).toEqual([['git', 'commit', '-am', 'wip']])
263
303
  })
@@ -267,7 +307,7 @@ test('a Commit answer commits', async () => {
267
307
 
268
308
  ```ts
269
309
  type Shown = {
270
- readonly toasts: string[]
310
+ readonly toasts: ShownToast[]
271
311
  readonly notes: string[]
272
312
  readonly prompts: string[]
273
313
  readonly logs: string[]
@@ -277,10 +317,13 @@ type Shown = {
277
317
  readonly commands: string[]
278
318
  readonly tools: string[]
279
319
  readonly schedules: { readonly id: string; readonly cron: string; readonly prompt: string }[]
320
+ readonly sent: { readonly session: string; readonly type: string; readonly text: string; readonly as?: string }[]
280
321
  }
322
+
323
+ type ShownToast = { readonly type: ToastType; readonly message: string; readonly description?: string }
281
324
  ```
282
325
 
283
- 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.
326
+ What the person and Claude would see: the toasts shown now, with a text description, the notes the mod added for Claude with `sessions.current.send` or a `prompt` that follows the state, the prompts it sent to the current session, the messages it sent to other sessions in `sent`, 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.
284
327
 
285
328
  ```ts
286
329
  test('the Mode prompt follows the mode', async () => {