@cmodjs/core 1.6.0 → 2.0.1

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 (136) hide show
  1. package/docs/commands.md +7 -7
  2. package/docs/dependencies.md +1 -1
  3. package/docs/index.md +12 -11
  4. package/docs/jobs.md +12 -16
  5. package/docs/mod.md +111 -36
  6. package/docs/permissions.md +14 -18
  7. package/docs/session.md +171 -82
  8. package/docs/state.md +5 -5
  9. package/docs/testing.md +57 -41
  10. package/docs/ui.md +9 -9
  11. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  12. package/jobs/permissions/decide-permission.js +1 -1
  13. package/jobs/prompt.d.ts +1 -3
  14. package/jobs/prompt.d.ts.map +1 -1
  15. package/jobs/prompt.js +2 -21
  16. package/jobs/slash-command.d.ts +1 -1
  17. package/jobs/slash-command.d.ts.map +1 -1
  18. package/jobs/slash-command.js +1 -1
  19. package/jobs/status-line.d.ts +0 -1
  20. package/jobs/status-line.d.ts.map +1 -1
  21. package/jobs/status-line.js +3 -4
  22. package/mod.d.ts +53 -105
  23. package/mod.d.ts.map +1 -1
  24. package/mod.js +18 -80
  25. package/package.json +1 -1
  26. package/records.d.ts +1 -1
  27. package/records.js +1 -1
  28. package/register.d.ts.map +1 -1
  29. package/register.js +8 -5
  30. package/runtime/before.d.ts +11 -4
  31. package/runtime/before.d.ts.map +1 -1
  32. package/runtime/before.js +131 -67
  33. package/runtime/claude.d.ts +6 -1
  34. package/runtime/claude.d.ts.map +1 -1
  35. package/runtime/conversations.d.ts.map +1 -1
  36. package/runtime/conversations.js +82 -38
  37. package/runtime/deadline.d.ts +17 -0
  38. package/runtime/deadline.d.ts.map +1 -1
  39. package/runtime/deadline.js +34 -0
  40. package/runtime/dependencies.d.ts +2 -0
  41. package/runtime/dependencies.d.ts.map +1 -1
  42. package/runtime/dependencies.js +12 -0
  43. package/runtime/grants.d.ts.map +1 -1
  44. package/runtime/grants.js +13 -8
  45. package/runtime/hooks.d.ts +15 -11
  46. package/runtime/hooks.d.ts.map +1 -1
  47. package/runtime/hooks.js +8 -165
  48. package/runtime/lifecycle.d.ts +4 -2
  49. package/runtime/lifecycle.d.ts.map +1 -1
  50. package/runtime/lifecycle.js +96 -62
  51. package/runtime/permission-check.d.ts +15 -0
  52. package/runtime/permission-check.d.ts.map +1 -0
  53. package/runtime/permission-check.js +37 -0
  54. package/runtime/sessions.d.ts +35 -5
  55. package/runtime/sessions.d.ts.map +1 -1
  56. package/runtime/sessions.js +304 -82
  57. package/runtime/state.d.ts.map +1 -1
  58. package/runtime/state.js +14 -11
  59. package/runtime/tool-calls.d.ts +0 -1
  60. package/runtime/tool-calls.d.ts.map +1 -1
  61. package/runtime/tool-calls.js +0 -19
  62. package/session/context/turn/message/instruction-file.d.ts +9 -0
  63. package/session/context/turn/message/instruction-file.d.ts.map +1 -0
  64. package/session/context/turn/message/instruction-file.js +4 -0
  65. package/session/context/turn/message/message.d.ts +6 -4
  66. package/session/context/turn/message/message.d.ts.map +1 -1
  67. package/session/context/turn/message/message.js +12 -8
  68. package/session/context/turn/message/sender.d.ts +2 -1
  69. package/session/context/turn/message/sender.d.ts.map +1 -1
  70. package/session/context/turn/message/sender.js +4 -1
  71. package/session/context/turn/message/tool-call.d.ts +5 -0
  72. package/session/context/turn/message/tool-call.d.ts.map +1 -1
  73. package/session/context/turn/message/tool-result.d.ts +6 -1
  74. package/session/context/turn/message/tool-result.d.ts.map +1 -1
  75. package/session/context/turn/turn.d.ts +7 -0
  76. package/session/context/turn/turn.d.ts.map +1 -1
  77. package/session/conversation.d.ts +10 -3
  78. package/session/conversation.d.ts.map +1 -1
  79. package/session/conversation.js +14 -5
  80. package/session/goal/goal.d.ts +1 -1
  81. package/session/goal/goal.d.ts.map +1 -1
  82. package/session/goal/task.d.ts +2 -2
  83. package/session/goal/task.d.ts.map +1 -1
  84. package/session/session.d.ts +361 -26
  85. package/session/session.d.ts.map +1 -1
  86. package/session/session.js +40 -0
  87. package/src/jobs/permissions/decide-permission.ts +2 -1
  88. package/src/jobs/prompt.ts +3 -20
  89. package/src/jobs/slash-command.ts +2 -2
  90. package/src/jobs/status-line.ts +4 -7
  91. package/src/mod.ts +52 -147
  92. package/src/records.ts +1 -1
  93. package/src/register.ts +8 -5
  94. package/src/runtime/before.ts +140 -66
  95. package/src/runtime/claude.ts +5 -1
  96. package/src/runtime/conversations.ts +75 -37
  97. package/src/runtime/deadline.ts +38 -0
  98. package/src/runtime/dependencies.ts +12 -0
  99. package/src/runtime/grants.ts +11 -7
  100. package/src/runtime/hooks.ts +24 -163
  101. package/src/runtime/lifecycle.ts +110 -77
  102. package/src/runtime/permission-check.ts +47 -0
  103. package/src/runtime/sessions.ts +334 -91
  104. package/src/runtime/state.ts +15 -8
  105. package/src/runtime/tool-calls.ts +0 -19
  106. package/src/session/context/turn/message/instruction-file.ts +13 -0
  107. package/src/session/context/turn/message/message.ts +17 -11
  108. package/src/session/context/turn/message/sender.ts +5 -2
  109. package/src/session/context/turn/message/tool-call.ts +3 -0
  110. package/src/session/context/turn/message/tool-result.ts +6 -1
  111. package/src/session/context/turn/turn.ts +4 -0
  112. package/src/session/conversation.ts +21 -8
  113. package/src/session/goal/goal.ts +1 -1
  114. package/src/session/goal/task.ts +2 -2
  115. package/src/session/session.ts +227 -24
  116. package/src/testing/claude-code.ts +23 -0
  117. package/src/testing/fake-claude.ts +5 -1
  118. package/src/testing/fake-sessions.ts +38 -24
  119. package/src/testing.ts +88 -19
  120. package/testing/claude-code.d.ts +12 -0
  121. package/testing/claude-code.d.ts.map +1 -0
  122. package/testing/claude-code.js +10 -0
  123. package/testing/fake-claude.d.ts +4 -0
  124. package/testing/fake-claude.d.ts.map +1 -1
  125. package/testing/fake-claude.js +4 -1
  126. package/testing/fake-sessions.d.ts +8 -6
  127. package/testing/fake-sessions.d.ts.map +1 -1
  128. package/testing/fake-sessions.js +35 -20
  129. package/testing.d.ts +38 -9
  130. package/testing.d.ts.map +1 -1
  131. package/testing.js +58 -8
  132. package/docs/hooks.md +0 -259
  133. package/session/context/turn/message/document.d.ts +0 -7
  134. package/session/context/turn/message/document.d.ts.map +0 -1
  135. package/session/context/turn/message/document.js +0 -1
  136. package/src/session/context/turn/message/document.ts +0 -8
@@ -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 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.
3
+ A mod declares what it does beyond drawing and listening: 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
 
@@ -19,15 +19,15 @@ They sit in the `package.json` `cmod` key, beside `install` and `keys`, because
19
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
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
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`, `search`, `on`, `before`, `usage`, and `clients`. Also reading Claude Code's transcript files |
23
- | `"prompt": true` | Add text Claude reads and start turns | A session's `send`, a `before` answer that changes or refuses text, the mod's `rules/`, the `prompt` and `schedule` jobs, and the hook answers listed below |
22
+ | `"conversation": true` | Read your other Claude Code conversations | Every session but the one the mod runs in: its messages, turns, contexts, goals, tasks, and events, `sessions.list`, `sessions.search`, the `on` methods of `sessions`, and a `before` method on another session. Also reading Claude Code's transcript files. Hearing and reading the mod's own session needs no permission |
23
+ | `"prompt": true` | Add text Claude reads and start turns | A session's `send`, `commands.run`, a `Turn`'s `abort`, the mod's `rules/`, the `prompt` and `schedule` jobs, and the `before` answers listed below |
24
24
  | `"model": true` | Ask a model, which uses your plan | `model.complete` |
25
- | `"tasks": true` | Start tasks that work beside Claude | A session's `startTask`, and a `before(TaskStart)` or `before(TaskEnd)` answer |
26
- | `"tools": true` | Use and change Claude's tool calls | A session's `callTool`, a `before(ToolCall)` answer, and a listener or a job that changes a tool call's input |
25
+ | `"tasks": true` | Start tasks that work beside Claude | A session's `startTask`, and a `beforeTaskStart` answer |
26
+ | `"tools": true` | Use and change Claude's tool calls | A session's `callTool`, and the `before` answers listed below |
27
27
  | `"config": ["env.ANTHROPIC_BASE_URL"]` | Change env.ANTHROPIC_BASE_URL in your Claude Code settings | Each value `settings.user.update`, `settings.project.update`, and `settings.local.update` change. A key path covers its own value and every key below it, so `"env"` covers every key in `env`, and `await settings.user.update({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' } })` needs `"env.ANTHROPIC_BASE_URL"` or `"env"`. `mcpServers` covers the project's `.mcp.json` |
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 |
28
+ | `"approve": true` | Approve Claude's tool calls for you | an allow or an ask from the mod's permission rules or listeners, and a `retry`. A refusal needs no permission |
29
29
 
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)).
30
+ Reading files, drawing panes and renders, toasts, slash commands, tools of the mod's own, listeners on the mod's own session, and a `refuse` answer 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
 
@@ -77,22 +77,18 @@ The call rejects, and the error names the fix:
77
77
  - A declared call the person turned off names where to turn it on: `ci-watch calls model.complete without your grant to "Ask a model, which uses your plan". Turn it on in /mods ci-watch.`
78
78
  - A `program` job without its grant stops at once with the same line, with no restarts.
79
79
 
80
- ## When a hook answer has no grant
80
+ ## When a before answer has no grant
81
81
 
82
- cmod checks the answer each hook returns, part by part. A part without its grant is dropped, and cmod logs once per permission which part it dropped and how to fix it:
83
-
84
- ```text
85
- ci-watch answered additionalContext without your grant to "Add text Claude reads and start turns", so cmod dropped it. Turn it on in /mods ci-watch.
86
- ```
82
+ cmod checks the answer each `before` listener returns, part by part. A part without its grant changes nothing, and cmod shows the error toast `The <method> listener failed` or logs once which part it dropped and the permission to turn on.
87
83
 
88
84
  | Answer part | Permission |
89
85
  | --- | --- |
90
- | `additionalContext`, `initialUserMessage`, `suppressOriginalPrompt`, `updatedToolOutput`, a `prompt` job's text | `prompt` |
91
- | a Stop or PostToolUse block | `prompt` |
92
- | `updatedInput`, and a changed tool input passed down the chain | `tools` |
93
- | an allow or an ask, and `retry` | `approve` |
86
+ | `text`, `note`, `title`, and `firstPrompt`, a `prompt` job's text, a `beforeToolResult` answer, a `beforeTurnEnd` refusal, which keeps Claude going, and every `commands.beforeRun` answer but a refusal | `prompt` |
87
+ | a `beforeToolCall` answer | `tools` |
88
+ | a `beforeTaskStart` answer | `tasks` |
89
+ | an `allow`, with the `input` and `updates` a `beforePermissionRequest` allow carries, an `ask`, and `retry` | `approve` |
94
90
 
95
- A deny needs no permission. A tool of the mod's own, `mcp__<mod>__*`, answers freely.
91
+ Any other `refuse` needs no permission. A tool of the mod's own, `mcp__<mod>__*`, answers freely.
96
92
 
97
93
  ## Test permissions
98
94
 
package/docs/session.md CHANGED
@@ -8,7 +8,7 @@ import { Prompt, sessions } from '../node_modules/@cmodjs/core/mod.js'
8
8
  const prompts = (await sessions.current.messages()).filter((message) => message.type === Prompt)
9
9
  ```
10
10
 
11
- Reading a conversation needs the `conversation` permission. Sending to one and changing its text need `prompt`, calling a tool and changing a tool call need `tools`, and starting or changing a task needs `tasks` ([permissions.md](permissions.md)).
11
+ A mod hears and reads the session it runs in with no permission. Every other session needs the `conversation` permission. Sending to a session and changing its text need `prompt`, calling a tool and changing a tool call need `tools`, and starting or changing a task needs `tasks`. A `refuse` answer needs no permission ([permissions.md](permissions.md)).
12
12
 
13
13
  ## The shape of a conversation
14
14
 
@@ -31,27 +31,27 @@ Every part of the record answers the same three calls: `search` finds its messag
31
31
 
32
32
  ```ts
33
33
  type Sessions = {
34
- readonly current: Session
34
+ readonly current: CurrentSession
35
35
  list(options?: { running?: boolean; since?: string; everyProject?: boolean }): Promise<Session[]>
36
- search(text: string, options?: { running?: boolean; since?: string; everyProject?: boolean; limit?: number; before?: Message }): Promise<Message[]>
37
- on(event, listener): Disposable
38
- on(event, matcher, listener): Disposable
39
- before(event, listener): Disposable
40
- before(event, matcher, listener): Disposable
36
+ search(text: string, matcher?: Matcher<Message>, options?: { running?: boolean; since?: string; everyProject?: boolean; before?: Message }): AsyncIterable<Message>
37
+ onMessage(matcher?, listener, options?): Disposable // and every on method below
41
38
  }
42
39
  ```
43
40
 
44
41
  - `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.
45
42
  - `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`.
46
- - `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.
43
+ - `search` yields the messages whose text contains `text`, any case, newest first, in the project's sessions or, with `everyProject`, every project's. `text` is at least 3 characters. It reads the index a page at a time as the loop asks for more, and `break` stops it. A matcher keeps the messages it matches, and `before` starts after a message, so a "load more" picks up where a loop broke. A tool call is searched by its name and the first 1,024 characters of each input value, an instruction file by its path and text, and a tool result is not searched.
47
44
 
48
45
  ```ts
49
- const earlier = await sessions.search('deploy job', { since: '1w' })
50
- const last = earlier.at(-1)
51
- const older = last === undefined ? [] : await sessions.search('deploy job', { since: '1w', before: last })
46
+ const found: Message[] = []
47
+ for await (const message of sessions.search('deploy job', { type: Prompt }, { since: '1w' })) {
48
+ found.push(message)
49
+ if (found.length === 20) break
50
+ }
51
+ const more = sessions.search('deploy job', { type: Prompt }, { since: '1w', before: found.at(-1) })
52
52
  ```
53
53
 
54
- - `on` and `before` hear every running session of the project, this one included ([Listen](#listen-to-a-record), [Change what happens](#change-what-happens)).
54
+ - Its `on` methods hear every running session of the project, this one included, and need the `conversation` permission ([Listen](#listen-to-a-record)). `sessions.current`'s methods hear this session with none. `sessions` has no `before` methods. To change what happens in another session, call a `before` method on that session ([Change what happens](#change-what-happens)).
55
55
 
56
56
  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)).
57
57
 
@@ -69,22 +69,20 @@ type Session = {
69
69
  readonly startedAt: string
70
70
  readonly lastAt: string
71
71
  readonly forkedFrom?: string
72
- goal(): Promise<Goal>
72
+ readonly goal: Goal
73
73
  context(): Promise<Context>
74
74
  contexts(): Promise<Context[]>
75
75
  turn(): Promise<Turn | undefined>
76
76
  turns(options?: { last?: number }): Promise<Turn[]>
77
77
  messages(options?: { after?: string; last?: number }): Promise<Message[]>
78
- search(text: string, options?: { limit?: number; before?: Message }): Promise<Message[]>
79
- on(event, listener): Disposable
80
- on(event, matcher, listener): Disposable
81
- before(event, listener): Disposable
82
- before(event, matcher, listener): Disposable
78
+ search(text: string, matcher?: Matcher<Message>): AsyncIterable<Message>
79
+ onMessage(matcher?, listener, options?): Disposable // and every on method below
80
+ beforePrompt(matcher?, listener, options?): Disposable // and every before method below
83
81
  send(message: { type: Prompt | Note; text: string; as?: Person | Session }): Promise<void>
84
- startTask(task: { prompt: string; agentType?: string; model?: string; name?: string; cwd?: string }): Promise<Task>
82
+ startTask(task: { prompt: string; title: string; agentType?: string; model?: string; name?: string; cwd?: string }): Promise<Task>
85
83
  callTool(call: { name: string; input: Record<string, unknown> }): Promise<ToolResult>
86
84
  usage(): Promise<Usage>
87
- clients(): Promise<Client[]>
85
+ surfaces(): Promise<Surface[]>
88
86
  }
89
87
  ```
90
88
 
@@ -97,16 +95,16 @@ type Session = {
97
95
 
98
96
  ### Read a record
99
97
 
100
- - `goal` answers its goal. The goal's `tasks()` answers the tasks the session handed on, and each task's `owner` reads like any session.
98
+ - `goal` is its goal. `sessions.current.goal` follows the session the mod runs in, as `sessions.current` does, so a listener added on it in `setup` keeps hearing across `/clear`, `/resume`, and a fork. The goal's `text()` reads its text now, and its `tasks()` answers the tasks the session handed on. Each task's `owner` reads like any session.
101
99
  - `context` answers the context it is in now, and `contexts` all of them in order. A context's `turns()` answers its own.
102
100
  - `turn` answers the turn running now, and nothing between turns. `turns` answers its turns in order, `last` keeping that many from the end. A turn's `messages()` answers its own.
103
101
  - `messages` answers its messages in order, those of its tasks left out. `last` keeps that many from the end, and `after` starts after a message's `id`.
104
- - `search` finds its messages whose text contains `text`, newest first, the way `sessions.search` does. A goal's, a task's, a context's, and a turn's `search` find only their own.
102
+ - `search` yields its messages whose text contains `text`, newest first, the way `sessions.search` does. A goal's, a task's, a context's, and a turn's `search` find only their own.
105
103
 
106
104
  ```ts
107
105
  const [last] = await sessions.current.turns({ last: 1 })
108
106
  const calls = (await last?.messages() ?? []).filter((message) => message.type === ToolCall)
109
- const explorers = (await (await sessions.current.goal()).tasks()).filter((task) => task.owner.agentType === 'Explore')
107
+ const explorers = (await sessions.current.goal.tasks()).filter((task) => task.owner.agentType === 'Explore')
110
108
  ```
111
109
 
112
110
  A session that has ended keeps its record, so every read works on it.
@@ -114,73 +112,123 @@ A session that has ended keeps its record, so every read works on it.
114
112
  ### Listen to a record
115
113
 
116
114
  ```ts
117
- part.on(event: SessionEvent, listener: (payload) => unknown): Disposable
118
- part.on(event: SessionEvent, matcher: Matcher<payload>, listener: (payload) => unknown): Disposable
115
+ part.on<Event>(listener: (payload, time: { deadline: number; signal: AbortSignal }) => unknown, options?: { timeoutMs?: number }): Disposable
116
+ part.on<Event>(matcher: Matcher<payload>, listener, options?): Disposable
119
117
  ```
120
118
 
121
- `on` runs `listener` for each new message, turn, context, task, goal change, or usage change, as it happens. `sessions`, a session, its goal, a task, a context, and a turn each have `on`, and each hears what happens inside it: an event reaches the part it happens in and every part above it.
119
+ Each event has its own method, such as `onReply` or `onTaskStart`, which runs `listener` for each new message, turn, context, task, goal change, or usage change, as it happens. `sessions`, a session, its goal, a task, a context, and a turn each have every `on` method, and each hears what happens inside it: an event reaches the part it happens in and every part above it.
120
+
121
+ A session is one agent: the conversation's main agent, or the agent doing a task. An event runs up one chain:
122
122
 
123
123
  ```
124
- Message ──> Turn ──> Context ──> Session ──> sessions
125
- TaskStart, TaskEnd, and the owner's record ──> Task ──> its parent Task ──> Goal ──> Session
124
+ Turn ──> Context ──> Session ──> that session's Task ──> each Task above ──> Goal ──> sessions
126
125
  ```
127
126
 
128
- So `turn.on(Reply, …)` hears the replies of that turn only, `task.on(Message, …)` hears every message of the task's owner and of the tasks it handed on, and `sessions.on(Prompt, …)` hears every prompt of every running session in the project.
127
+ The goal belongs to the main agent, so the main agent's events reach it too. So:
128
+ - `sessions.current.onToolCall(…)` hears the main agent's tool calls only.
129
+ - `sessions.current.goal.onToolCall(…)` hears every agent's in the session.
130
+ - `task.onMessage(…)` hears every message of the task's owner and of the tasks it handed on, and `task.owner.onMessage(…)` hears the owner's alone.
131
+ - `turn.onReply(…)` hears the replies of that turn only, and `sessions.onPrompt(…)` hears every prompt of every running session in the project.
129
132
 
130
- | Event | The listener gets | It happens in |
133
+ A turn or a context that has ended takes no listener: its methods throw `<mod>: turn <id> has ended, so its onReply never fires. Listen on its Session.`
134
+
135
+ | Method | The listener gets | It happens in |
131
136
  | --- | --- | --- |
132
- | `Message` | Every new message. | Its turn |
133
- | `Prompt`, `Note`, `Reply`, `ToolCall`, `ToolResult`, `Skill`, `Document`, `Notice` | Each new message of that type. | Its turn |
134
- | `TurnStart`, `TurnEnd` | The `Turn` that started or ended. | Its context |
135
- | `ContextStart`, `ContextEnd` | The `Context` that started or ended. | The session |
136
- | `TaskStart`, `TaskEnd` | The `Task` that started or ended. | Its parent task, or the goal |
137
- | `GoalChange` | The `Goal` with its new text. | The goal |
138
- | `UsageChange` | The session's new `Usage`. | The session |
137
+ | `onMessage` | Every new message. | Its turn |
138
+ | `onPrompt`, `onNote`, `onReply`, `onToolCall`, `onToolResult`, `onSkill`, `onInstructionFile`, `onNotice` | Each new message of that type. | Its turn |
139
+ | `onTurnStart`, `onTurnEnd` | The `Turn` that started or ended. | Its context |
140
+ | `onContextStart`, `onContextEnd` | The `Context` that started or ended. | The session |
141
+ | `onTaskStart`, `onTaskEnd` | The `Task` that started or ended. | Its parent task, or the goal |
142
+ | `onGoalChange` | The `Goal` with its new text. | The goal |
143
+ | `onUsageChange` | The session's new `Usage`. | The session |
139
144
 
140
145
  The current session's events arrive as Claude Code writes them, and another running session's within a moment.
141
146
 
142
147
  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. A key the payload lacks matches nothing.
143
148
 
144
149
  ```ts
145
- import { Reply, sessions, TaskStart, ToolCall } from '../node_modules/@cmodjs/core/mod.js'
150
+ import { sessions } from '../node_modules/@cmodjs/core/mod.js'
151
+
152
+ sessions.current.onToolCall({ name: /^(Edit|Write)$/ }, (call) => void (mod.state.session.edits += 1))
153
+ sessions.current.onTaskStart({ owner: { agentType: 'Explore' } }, (task) => mod.log(`Exploring: ${task.title}`))
154
+ ```
155
+
156
+ A listener that throws shows the error toast `The <method> listener failed`, such as `The onReply listener failed`. `dispose()` on the `Disposable` stops the listener.
157
+
158
+ ### Give a listener time
146
159
 
147
- sessions.current.on(ToolCall, { name: /^(Edit|Write)$/ }, (call) => void (mod.state.session.edits += 1))
148
- sessions.current.on(TaskStart, { owner: { agentType: 'Explore' } }, (task) => mod.log(`Exploring: ${task.title}`))
160
+ A listener gets 10 seconds. `timeoutMs` changes that, a whole number above 0 and at most `600000`. The second argument a listener gets is its `deadline`, the time it ends as `Date.now()` counts, and a `signal` that aborts then, so a slow call such as `http.fetch` can stop with it:
161
+
162
+ ```ts
163
+ sessions.current.onTurnEnd(async (turn, { signal }) => {
164
+ await http.fetch('https://hooks.example.com/turn', { method: 'POST', body: turn.answer ?? '', signal })
165
+ }, { timeoutMs: 30_000 })
149
166
  ```
150
167
 
151
- A listener that throws shows the error toast `The <event> listener failed`. `dispose()` on the `Disposable` stops the listener.
168
+ An `on` listener that passes its time stops quietly. A `before` listener that passes its time shows the error toast, and what was about to happen goes on as it was. With `onFailure: 'refuse'`, a `before` listener that throws or passes its time refuses instead, with the error as the reason, for a gate that must hold even when it breaks.
152
169
 
153
170
  ### Change what happens
154
171
 
155
172
  ```ts
156
- part.before(event: BeforeEvent, listener: (payload) => Answer | void): Disposable
157
- part.before(event: BeforeEvent, matcher: Matcher<payload>, listener: (payload) => Answer | void): Disposable
173
+ part.before<Event>(listener: (payload, time) => Answer | void, options?: { timeoutMs?: number; onFailure?: 'pass' | 'refuse' }): Disposable
174
+ part.before<Event>(matcher: Matcher<payload>, listener, options?): Disposable
158
175
  ```
159
176
 
160
- `before` runs `listener` just before something happens, and its answer changes or refuses it. Returning nothing lets it happen as it was. Each part has `before`, and hears what is about to happen inside it, the way `on` does.
177
+ Each `before` method, such as `beforeToolCall`, runs `listener` just before something happens, and its answer changes or refuses it. Returning nothing lets it happen as it was. Each part except `sessions` has every `before` method, and hears what is about to happen inside it, the way `on` methods do.
161
178
 
162
- | Event | The listener can answer | Covers |
179
+ | Method | The listener can answer | Covers |
163
180
  | --- | --- | --- |
164
- | `Prompt` | `{ text }` or `{ refuse }` | A typed prompt, a mod's prompt, a slash command, and a message from another session |
165
- | `Note` | `{ text }` | A note any mod or Claude Code adds |
166
- | `Skill` | `{ text }` | A Skill as Claude Code loads it |
167
- | `Document` | `{ text }` or `{ refuse }` | A `CLAUDE.md` file as Claude Code reads it. `refuse` leaves it out |
168
- | `Reply`, `ToolResult`, `Notice` | `{ text }` | The text the transcript keeps |
169
- | `ToolCall` | `{ input }`, `{ result }`, or `{ refuse }` | A tool call. `result` answers it without running the tool |
170
- | `TaskStart` | `{ owner: { model } }` or `{ refuse }` | A task about to start, and the model its owner runs |
171
- | `TaskEnd` | `{ refuse }` | A task about to end. `refuse` sends its owner on with the reason |
172
- | `TurnEnd` | `{ refuse }` | The agent about to stop. `refuse` sends it on with the reason |
173
- | `ContextEnd` | `{ refuse }` | A compaction about to start. `refuse` keeps the context |
181
+ | `beforePrompt` | `{ text?, note?, title? }` or `{ refuse }` | A typed prompt, a mod's prompt, a command Claude Code sends as a prompt, such as a project's `/review`, and a message from another session. `note` reaches Claude beside a typed or a mod's prompt, and `title` names the session. A task's first prompt reaches `beforePrompt` as the subagent starts, where `note` is the only answer and the subagent reads it before its first step. To keep a task from starting, refuse it in `beforeTaskStart`. A built-in command such as `/model` is never a prompt: `commands.beforeRun` hears every command ([mod.md](mod.md#commands-slash-commands)). |
182
+ | `beforeNote` | `{ text }` | A note any mod or Claude Code adds |
183
+ | `beforeSkill` | `{ text }` | A Skill as Claude Code loads it |
184
+ | `beforeInstructionFile` | `{ text }` or `{ refuse }` | A `CLAUDE.md` file as Claude Code reads it. `refuse` leaves it out |
185
+ | `beforeReply`, `beforeNotice` | `{ text }` | The text the transcript keeps |
186
+ | `beforeToolResult` | `{ output?, isError?, note? }` | The result, after the tool ran. Here `output` is what the tool answered Claude Code, such as `{ stdout, stderr }` for Bash, and an `output` answer replaces it in the same shape, `note` adds text beside it, and `isError: true` makes Claude read it as a failure, with the note as the reason. A failed call takes only a note |
187
+ | `beforeToolCall` | `{ input }`, `{ result }`, or `{ refuse }` | A tool call. `result` answers it without running the tool |
188
+ | `beforeTaskStart` | `{ owner: { model } }` or `{ refuse }` | A task about to start, and the model its owner runs |
189
+ | `beforeTurnEnd` | `{ refuse }` | The agent about to stop. `refuse` sends it on with the reason. On `task.owner`, it is the task about to hand back its answer |
190
+ | `beforeContextEnd` | `{ refuse }` | A compaction about to start. `refuse` keeps the context |
174
191
 
175
192
  ```ts
176
- sessions.current.before(ToolCall, { name: 'Bash', input: { command: /\brm -rf\b/ } }, () => ({ refuse: 'Delete files one at a time.' }))
177
- sessions.current.before(TaskStart, { owner: { agentType: 'Explore' } }, () => ({ owner: { model: 'haiku' } }))
178
- sessions.current.before(TurnEnd, async () => ((await testsPass()) ? undefined : { refuse: 'The tests fail. Fix them before you stop.' }))
193
+ sessions.current.beforeToolCall({ name: 'Bash', input: { command: /\brm -rf\b/ } }, () => ({ refuse: 'Delete files one at a time.' }))
194
+ sessions.current.beforeTaskStart({ owner: { agentType: 'Explore' } }, () => ({ owner: { model: 'haiku' } }))
195
+ sessions.current.beforeTurnEnd(async () => ((await testsPass()) ? undefined : { refuse: 'The tests fail. Fix them before you stop.' }))
179
196
  ```
180
197
 
181
- An answer that changes text needs the `prompt` permission, a `ToolCall` answer needs `tools`, and a `TaskStart` or `TaskEnd` answer needs `tasks`. Without it the answer changes nothing, and the toast `The before(<event>) listener failed` names the permission. When several listeners answer, each sees what the one before it changed, and the first `refuse` or `result` decides.
198
+ An answer that changes text needs the `prompt` permission, a `beforeToolCall` answer needs `tools`, and a `beforeTaskStart` answer needs `tasks`. Without it the answer changes nothing, and the toast `The <method> listener failed` names the permission. When several listeners answer, each sees what the one before it changed, and the first `refuse` or `result` decides.
199
+
200
+ ### The session's own events
201
+
202
+ `sessions.current` is a `CurrentSession`, which also hears what happens to the session itself. Each event has an `on` method, and one a mod can change also has a `before` method. A `before` method's answer reaches Claude Code, and an `on` listener never makes Claude Code wait. These methods hear the main agent, never a subagent.
203
+
204
+ | Method | Claude Code's moment | The payload | The listener can answer |
205
+ | --- | --- | --- | --- |
206
+ | `beforeProjectSetup`, `onProjectSetup` | `claude --init-only` sets up the project | `ProjectSetup`: `trigger` | nothing: Claude Code waits for it |
207
+ | `beforeSessionStart`, `onSessionStart` | a startup, resume, `/clear`, or fork, and for `beforeSessionStart` a compaction | `SessionStartAsked`, `SessionStarted`: `source`, `model?`, `title?`, `idleSeconds?`, `contextTokenCount?`, `isCacheWarm?`, `cacheWriteUsd?` | `{ note?, title?, reloadSkills?, firstPrompt? }` |
208
+ | `onSessionEnd` | the session ends | `SessionEnded`: `reason` | |
209
+ | `beforeModelChange`, `onModelChange` | `/model` switches the model | `ModelChange`: `model`, `previous`, `requested?`, `source`, `contextTokenCount`, `isCacheWarm`, `cacheTtl`, `cacheWriteUsd`, `pricing?` | `{ ask }` or `{ refuse }` |
210
+ | `onCwdChange` | the working folder moves | `CwdChange`: `previous`, and `cwd` is the new folder | |
211
+ | `onDirectoryAdd` | `/add-dir` adds a folder | `DirectoryAdd`: `directory`, `source` | |
212
+ | `beforeWorktreeCreate`, `onWorktreeCreate`, `onWorktreeRemove` | a worktree is made or removed | `Worktree`: `name?`, `path?` | `{ result }`, the worktree's path |
213
+ | `onNotification` | Claude Code notifies the person | `Notification`: `text`, `title?`, `type` | |
214
+ | `beforeElicitation`, `onElicitation` | an MCP server asks the person | `Elicitation`: `id?`, `server`, `text`, `mode?`, `url?`, `schema?` | `{ refuse }` |
215
+ | `beforeElicitationResponse`, `onElicitationResponse` | the person answers an MCP server | `ElicitationResponse`: `id?`, `server`, `mode?`, `action`, `content?` | `{ refuse }` |
216
+ | `beforePermissionCheck`, `onPermissionCheck` | Claude Code decides whether a tool call runs, asks, or is refused, before any dialog, and when a mod asks `tools.check` | `PermissionCheck`: `name`, `input`, `callId?`, `decision`, `reason?`, `rule?`, `ceiling?` | `{ allow: true }`, `{ ask }`, or `{ refuse }`. `allow` approves a call in every mode, auto mode included, and needs `approve`. A mod never overrides a refusal from the person's settings |
217
+ | `beforePermissionRequest`, `onPermissionRequest` | a permission dialog is about to show | `PermissionRequest`: `name`, `input`, `suggestions`, `mcpServer?` | `{ allow: true, input?, updates? }` or `{ refuse, interrupt? }` |
218
+ | `beforePermissionDenial`, `onPermissionDenial` | a tool call was denied | `PermissionDenial`: `name`, `input`, `callId`, `reason`, `mcpServer?` | `{ retry: true }` |
219
+ | `beforeToolBatch`, `onToolBatch` | a batch of tool calls finished | `ToolBatch`: `calls`, each `{ callId, name, input, output? }` | `{ note }` |
220
+ | `beforeTodoCreate`, `onTodoCreate`, `beforeTodoComplete`, `onTodoComplete` | a todo is added to or ticked on the checklist | `Todo`: `id`, `subject`, `description?`, `teammate?` | `{ refuse }` |
221
+ | `beforeReplyDisplay`, `onReplyDisplay` | a reply is about to show | `ReplyDisplay`: `replyId`, `index`, `isLast`, `text` | `{ text }`, what the person sees |
222
+
223
+ Every payload also carries where it happened: `session`, `cwd`, and `agentType?` and `permissionMode?` when Claude Code reports them. A matcher keeps the payloads it matches:
182
224
 
183
- `before` on another session, and `sessions.before`, ask the mod over cmod from that session. That session waits up to 5 seconds for the answer, then lets the event happen as it was and logs why. `sessions.before` answers once for each event, whichever session it happens in.
225
+ ```ts
226
+ sessions.current.beforeSessionStart({ source: 'compact' }, () => ({ note: 'Re-read DESIGN.md before you continue.' }))
227
+ sessions.current.beforeModelChange({ model: /haiku/ }, () => ({ refuse: 'This project stays on opus.' }))
228
+ sessions.current.onCwdChange(({ cwd, previous }) => mod.log(`moved from ${previous} to ${cwd}`))
229
+ ```
230
+
231
+ A `before` method on another session asks the mod over cmod from that session. That session waits up to 5 seconds for the answer, then lets the event happen as it was and logs why.
184
232
 
185
233
  ### Act on a session
186
234
 
@@ -191,12 +239,12 @@ Every action works on the current session and on every other running session. cm
191
239
  - A `Prompt` is the next prompt, so the agent 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`, the agent 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.
192
240
  - `as: Session` sends it to another session as a message from this one, which Claude Code shows as one session writing to another.
193
241
  - To a task's owner, `send` delivers the text to that running subagent.
194
- - `startTask` starts a task in the session, the way the Agent tool starts one, and resolves the `Task` once it started. It does not wait for the task to finish. `agentType` names the owner's agent type, such as `Explore`, `name` titles the task, and `model` and `cwd` change what it runs on and where. It needs the `tasks` permission, and rejects with `<mod>: Claude Code refused the task: <reason>` when a hook refuses it.
242
+ - `startTask` starts a task in the session, the way the Agent tool starts one, and resolves the `Task` once it started. It does not wait for the task to finish. `title` is the task's title, `agentType` names the owner's agent type, such as `Explore`, `name` is the address a teammate's `SendMessage` reaches it by, and `model` and `cwd` change what it runs on and where. It needs the `tasks` permission, and rejects with `<mod>: Claude Code refused the task: <reason>` when a hook refuses it.
195
243
  - `callTool` runs a tool in the session, such as `{ name: 'Bash', input: { command: 'bun test' } }`, and resolves its `ToolResult`. It needs the `tools` permission.
196
- - `usage` answers the 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 } }`. For an ended session and a task's owner it answers the model and the tokens of its last reply, from its transcript.
197
- - `clients` answers the Claude apps showing the session now: `'terminal'`, `'desktop'`, `'mobile'`, and `'vscode'`. An ended session shows in none.
244
+ - `usage` answers the session's model, its context window's fill, and its cost so far, as the status line shows them: `{ model, contextWindow: { tokenCount?, size?, percent? }, cost?: { usd } }`. For an ended session and a task's owner it answers the model and the tokens of its last reply, from its transcript.
245
+ - `surfaces` answers the Claude apps showing the session now: `'terminal'`, `'desktop'`, `'mobile'`, and `'vscode'`. An ended session shows in none.
198
246
 
199
- A task's owner takes `send`, `usage`, and `clients`. Claude Code gives a subagent no way to start a task or run a tool from outside, so its `startTask` and `callTool` reject. An action on a session that has ended rejects with `session <id> has ended`, and `send` with `<mod>: session <id> has ended, so it takes no messages.`
247
+ A task's owner is a `TaskOwner`, a session without `startTask` and `callTool`, because Claude Code gives a subagent no way to start a task or run a tool from outside. It takes `send`, `usage`, and `surfaces`. An action on a session that has ended rejects with `session <id> has ended`, and `send` with `<mod>: session <id> has ended, so it takes no messages.`
200
248
 
201
249
  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)).
202
250
 
@@ -209,15 +257,21 @@ mod.every(60_000, async () => {
209
257
  })
210
258
  ```
211
259
 
212
- The task's answer arrives once it finishes, on a `TaskEnd` listener:
260
+ The task's answer arrives once it finishes, on an `onTaskEnd` listener:
213
261
 
214
262
  ```ts
215
- mod.on(UserPromptSubmit, { prompt: /^\/summarize/ }, async () => {
216
- const task = await sessions.current.startTask({ prompt: 'Summarize README.md in one line.', agentType: 'Explore', name: 'Summarize the README' })
217
- const ended = await new Promise<Task>((resolve) => task.on(TaskEnd, resolve))
218
- const [answer] = await ended.owner.messages({ last: 1 })
219
- return { hookSpecificOutput: { hookEventName: UserPromptSubmit, additionalContext: answer?.type === Reply ? answer.text : '' } }
220
- })
263
+ mod.use(slashCommand({
264
+ name: 'summarize',
265
+ description: 'Summarize the README in one line',
266
+ async reply() {
267
+ const task = await sessions.current.startTask({ prompt: 'Summarize README.md in one line.', title: 'Summarize the README', agentType: 'Explore' })
268
+ task.onTaskEnd(async (ended) => {
269
+ const [answer] = await ended.owner.messages({ last: 1 })
270
+ if (answer?.type === Reply) mod.ui.toast(answer.text)
271
+ })
272
+ return 'Summarizing the README.'
273
+ },
274
+ }))
221
275
  ```
222
276
 
223
277
  ## Message
@@ -243,15 +297,30 @@ type MessageOf<Type> = {
243
297
 
244
298
  | Type | Constant | Its own fields |
245
299
  | --- | --- | --- |
246
- | `'prompt'` | `Prompt` | `text`, `from`: the prompt that opens a turn. A slash command reads as the command typed, such as `/review 12`. |
300
+ | `'prompt'` | `Prompt` | `text`, `from`: the prompt that opens a turn. A command Claude Code sends as a prompt reads as typed, such as `/review 12`. |
247
301
  | `'note'` | `Note` | `text`, `from`: text Claude reads that is not a prompt, such as a mod's note or a task's notification. |
248
302
  | `'reply'` | `Reply` | `text`: what Claude said. |
249
- | `'toolCall'` | `ToolCall` | `callId`, `name`, `input`, `cwd`: a tool Claude called, and the folder it called it in. |
250
- | `'toolResult'` | `ToolResult` | `callId`, `text`, `isError`: what the tool answered. |
303
+ | `'toolCall'` | `ToolCall` | `callId`, `name`, `input`, `cwd`, `files`: a tool Claude called, the folder it called it in, and the files it reads and changes. |
304
+ | `'toolResult'` | `ToolResult` | `callId`, `name`, `input`, `cwd`, `files`, `output`, `isError`: the call's tool, input, folder, and files, and what the tool answered. `output` is the text Claude read, or the tool's own result for `callTool`. |
251
305
  | `'skill'` | `Skill` | `name`, `text`: a Skill Claude Code loaded. |
252
- | `'document'` | `Document` | `path`, `text`: a `CLAUDE.md` file Claude Code read for a folder. |
306
+ | `'instructionFile'` | `InstructionFile` | `path`, `text`, `isRule`: a `CLAUDE.md` file or a rule file Claude Code read. `isRule` is true for a file in a `.claude/rules/` folder. |
253
307
  | `'notice'` | `Notice` | `text`: a line Claude Code added, such as a model switch. |
254
308
 
309
+ ### files: what a tool call reads and changes
310
+
311
+ A `ToolCall`'s and a `ToolResult`'s `files` is `{ read: string[]; changed: string[] }`, absolute paths that cmod works out from the call:
312
+
313
+ - `Read` reads its `file_path`. `Edit`, `Write`, and `NotebookEdit` change their path.
314
+ - `Grep` counts its `path`, or the working folder when it names none, and `Glob` counts its `pattern` joined to its `path`. Since `read` lists only files, a `Grep` of a folder and every `Glob` usually leave `read` empty.
315
+ - A `Bash` command reads and changes the files its commands name, such as `cat a.ts > b.ts`. A path that holds a shell expansion, such as `$HOME`, is left out.
316
+ - `read` lists only paths that are files now. `changed` lists every path the call writes, whether it exists or not.
317
+ - A path cmod cannot check, such as one it has no permission to read, is left out of `read`, and cmod writes the path and the reason to Claude Code's debug log.
318
+ - When cmod cannot work the files out, both lists are empty, and the reason goes to Claude Code's debug log.
319
+
320
+ ```ts
321
+ sessions.current.onToolResult(({ files }) => void (mod.state.session.edited += files.changed.length))
322
+ ```
323
+
255
324
  `Message` is any of these, and `message.type` tells them apart:
256
325
 
257
326
  ```ts
@@ -277,33 +346,53 @@ type Sender = { readonly type: SenderType; readonly name?: string; readonly as?:
277
346
  | `Schedule` (`'schedule'`) | A scheduled prompt. |
278
347
  | `Channel` (`'channel'`) | A channel, such as Slack, named by `name`. |
279
348
  | `Sdk` (`'sdk'`) | A program driving Claude Code through the SDK. |
349
+ | `Engine` (`'engine'`) | Claude Code itself, such as the prompt that keeps a turn going after it hit its output limit. |
280
350
 
281
351
  ## Turn, Context, Goal, and Task
282
352
 
283
353
  ```ts
284
- type Turn = { readonly prompt: Prompt; readonly context: number; readonly openedAt: string; readonly closedAt?: string; messages(): Promise<Message[]>; search; on; before }
354
+ type Turn = {
355
+ readonly prompt: Prompt
356
+ readonly context: number
357
+ readonly openedAt: string
358
+ readonly closedAt?: string
359
+ readonly answer?: string
360
+ readonly reason?: 'answer' | 'error'
361
+ readonly failure?: { readonly type: string; readonly text: string }
362
+ messages(): Promise<Message[]>
363
+ abort(): Promise<void>
364
+ search; on…; before…
365
+ }
285
366
 
286
- type Context = { readonly number: number; readonly openedAt: string; readonly closedAt?: string; turns(): Promise<Turn[]>; search; on; before }
367
+ type Context = { readonly number: number; readonly openedAt: string; readonly closedAt?: string; turns(): Promise<Turn[]>; search; on…; before… }
287
368
 
288
- type Goal = { readonly text: string; tasks(): Promise<Task[]>; search; on; before; update(change: { text: string }): Promise<void> }
369
+ type Goal = { text(): Promise<string>; tasks(): Promise<Task[]>; search; on…; before…; update(change: { text: string }): Promise<void> }
289
370
 
290
371
  type Task = {
291
372
  readonly id: string
292
373
  readonly title: string
293
- readonly owner: Session
374
+ readonly owner: TaskOwner
294
375
  readonly openedBy: string
295
376
  readonly openedAt: string
296
377
  readonly closedAt?: string
297
378
  tasks(): Promise<Task[]>
298
379
  messages(): Promise<Message[]>
299
- search; on; before
380
+ search; on…; before…
300
381
  }
301
382
  ```
302
383
 
303
384
  - A turn's `closedAt` is set once the agent stops. A turn still running has none.
385
+ - The turn `onTurnEnd` hears carries why it ended. `reason` is `'answer'` when Claude finished, with its last words in `answer`, and `'error'` when a request failed, with `failure` naming the error's `type`, such as `'rate_limit'`, and its `text`. A turn a mod kept going with `beforeTurnEnd` ends only at the stop that nothing refuses. A turn read later through `turns()` has `closedAt` and none of these.
386
+
387
+ ```ts
388
+ sessions.current.onTurnEnd((turn) => {
389
+ if (turn.failure !== undefined) mod.ui.toast.error(`Claude stopped: ${turn.failure.text}`)
390
+ })
391
+ ```
392
+ - `abort()` stops a turn while it runs, a task's turn included, and needs the `prompt` permission. It throws on a turn that has ended. Claude Code shows no marker, so a mod that aborts tells the person with a toast.
304
393
  - A context's `number` counts from 1. Its `closedAt` is set at the compaction that ended it.
305
- - A goal's `text` starts as the session's first prompt. `update` replaces it, and a `GoalChange` listener hears the new text.
306
- - A task's `owner` is the session doing the work. Its goal is the task's prompt, its `agentType` and `model` are what the task runs, and its turns and messages are the task's own. Claude Code's checklist tools, `TaskCreate`, `TaskUpdate`, and `TaskList`, are not tasks: they are its todo list, which the `TodoCreated` and `TodoCompleted` hooks hear ([hooks.md](hooks.md)).
394
+ - A goal's text starts as the session's first prompt, and `text()` reads it. `update` replaces it, and `onGoalChange` hears the goal, whose `text()` reads the new text.
395
+ - A task's `owner` is a `TaskOwner`: the session doing the work, which can't start tasks or call tools. Its goal is the task's prompt, its `agentType` and `model` are what the task runs, and its turns and messages are the task's own. Claude Code's checklist tools, `TaskCreate`, `TaskUpdate`, and `TaskList`, are not tasks: they are its todo list, which `onTodoCreate` and `onTodoComplete` hear.
307
396
  - 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.
308
397
 
309
398
  ## Test it
package/docs/state.md CHANGED
@@ -42,14 +42,14 @@ export const initialState: TasksState = {
42
42
  Then pass it to `defineMod`:
43
43
 
44
44
  ```ts
45
- import { defineMod, UserPromptSubmit } from '../node_modules/@cmodjs/core/mod.js'
45
+ import { defineMod, sessions } from '../node_modules/@cmodjs/core/mod.js'
46
46
  import { initialState } from './state.js'
47
47
 
48
48
  export const tasks = defineMod({
49
49
  name: 'tasks',
50
50
  state: initialState,
51
51
  setup(mod) {
52
- mod.on(UserPromptSubmit, () => {
52
+ sessions.current.onPrompt(() => {
53
53
  mod.state.session.prompts += 1
54
54
  })
55
55
  },
@@ -66,14 +66,14 @@ Assign to a key. cmod saves the new value and redraws the mod.
66
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:
67
67
 
68
68
  ```ts
69
- import { defineMod, PostToolUse } from '../node_modules/@cmodjs/core/mod.js'
69
+ import { defineMod, sessions } from '../node_modules/@cmodjs/core/mod.js'
70
70
 
71
71
  export const reads = defineMod({
72
72
  name: 'reads',
73
73
  state: { memory: { readFiles: [] as readonly string[] } },
74
74
  setup(mod) {
75
- mod.on(PostToolUse, (input) => {
76
- mod.state.memory.readFiles = [...mod.state.memory.readFiles, ...input.files.read]
75
+ sessions.current.onToolResult(({ files }) => {
76
+ mod.state.memory.readFiles = [...mod.state.memory.readFiles, ...files.read]
77
77
  })
78
78
  },
79
79
  })