@cmodjs/core 0.3.0 → 1.5.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 (280) hide show
  1. package/docs/commands.md +61 -10
  2. package/docs/dependencies.md +16 -14
  3. package/docs/hooks.md +64 -29
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +13 -9
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +227 -175
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +311 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +68 -23
  13. package/docs/ui.md +116 -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 +14 -4
  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 +178 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +127 -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 +13 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +33 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +34 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +68 -0
  61. package/runtime/before.d.ts +32 -0
  62. package/runtime/before.d.ts.map +1 -0
  63. package/runtime/before.js +214 -0
  64. package/runtime/bound.d.ts +4 -0
  65. package/runtime/bound.d.ts.map +1 -0
  66. package/runtime/bound.js +9 -0
  67. package/runtime/claude.d.ts +4 -2
  68. package/runtime/claude.d.ts.map +1 -1
  69. package/runtime/conversations.d.ts +17 -0
  70. package/runtime/conversations.d.ts.map +1 -0
  71. package/runtime/conversations.js +563 -0
  72. package/runtime/deadline.d.ts +0 -1
  73. package/runtime/deadline.d.ts.map +1 -1
  74. package/runtime/deadline.js +1 -1
  75. package/runtime/dependencies.d.ts +2 -0
  76. package/runtime/dependencies.d.ts.map +1 -1
  77. package/runtime/dependencies.js +10 -1
  78. package/runtime/engine.d.ts +13 -0
  79. package/runtime/engine.d.ts.map +1 -0
  80. package/runtime/engine.js +10 -0
  81. package/runtime/grants.d.ts +4 -0
  82. package/runtime/grants.d.ts.map +1 -1
  83. package/runtime/grants.js +68 -31
  84. package/runtime/hooks.d.ts +5 -4
  85. package/runtime/hooks.d.ts.map +1 -1
  86. package/runtime/hooks.js +29 -9
  87. package/runtime/installer.d.ts +1 -1
  88. package/runtime/installer.d.ts.map +1 -1
  89. package/runtime/installer.js +17 -14
  90. package/runtime/lifecycle.d.ts +3 -1
  91. package/runtime/lifecycle.d.ts.map +1 -1
  92. package/runtime/lifecycle.js +414 -212
  93. package/runtime/metadata.d.ts +3 -1
  94. package/runtime/metadata.d.ts.map +1 -1
  95. package/runtime/metadata.js +25 -14
  96. package/runtime/options.d.ts +3 -1
  97. package/runtime/options.d.ts.map +1 -1
  98. package/runtime/options.js +4 -4
  99. package/runtime/programs.d.ts +8 -0
  100. package/runtime/programs.d.ts.map +1 -1
  101. package/runtime/programs.js +39 -1
  102. package/runtime/router.d.ts +2 -1
  103. package/runtime/router.d.ts.map +1 -1
  104. package/runtime/router.js +11 -3
  105. package/runtime/sessions.d.ts +58 -0
  106. package/runtime/sessions.d.ts.map +1 -0
  107. package/runtime/sessions.js +433 -0
  108. package/runtime/state.d.ts +6 -2
  109. package/runtime/state.d.ts.map +1 -1
  110. package/runtime/state.js +46 -13
  111. package/runtime/toaster.d.ts +24 -0
  112. package/runtime/toaster.d.ts.map +1 -0
  113. package/runtime/toaster.js +286 -0
  114. package/runtime/ui.d.ts +6 -17
  115. package/runtime/ui.d.ts.map +1 -1
  116. package/runtime/ui.js +62 -118
  117. package/session/context/context.d.ts +12 -0
  118. package/session/context/context.d.ts.map +1 -0
  119. package/session/context/context.js +2 -0
  120. package/session/context/turn/message/document.d.ts +7 -0
  121. package/session/context/turn/message/document.d.ts.map +1 -0
  122. package/session/context/turn/message/document.js +1 -0
  123. package/session/context/turn/message/message.d.ts +55 -0
  124. package/session/context/turn/message/message.d.ts.map +1 -0
  125. package/session/context/turn/message/message.js +178 -0
  126. package/session/context/turn/message/note.d.ts +8 -0
  127. package/session/context/turn/message/note.d.ts.map +1 -0
  128. package/session/context/turn/message/note.js +1 -0
  129. package/session/context/turn/message/notice.d.ts +6 -0
  130. package/session/context/turn/message/notice.d.ts.map +1 -0
  131. package/session/context/turn/message/notice.js +1 -0
  132. package/session/context/turn/message/prompt.d.ts +8 -0
  133. package/session/context/turn/message/prompt.d.ts.map +1 -0
  134. package/session/context/turn/message/prompt.js +1 -0
  135. package/session/context/turn/message/reply.d.ts +6 -0
  136. package/session/context/turn/message/reply.d.ts.map +1 -0
  137. package/session/context/turn/message/reply.js +1 -0
  138. package/session/context/turn/message/sender.d.ts +14 -0
  139. package/session/context/turn/message/sender.d.ts.map +1 -0
  140. package/session/context/turn/message/sender.js +30 -0
  141. package/session/context/turn/message/skill.d.ts +7 -0
  142. package/session/context/turn/message/skill.d.ts.map +1 -0
  143. package/session/context/turn/message/skill.js +1 -0
  144. package/session/context/turn/message/tool-call.d.ts +9 -0
  145. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  146. package/session/context/turn/message/tool-call.js +1 -0
  147. package/session/context/turn/message/tool-result.d.ts +8 -0
  148. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  149. package/session/context/turn/message/tool-result.js +1 -0
  150. package/session/context/turn/turn.d.ts +13 -0
  151. package/session/context/turn/turn.d.ts.map +1 -0
  152. package/session/context/turn/turn.js +2 -0
  153. package/session/conversation.d.ts +73 -0
  154. package/session/conversation.d.ts.map +1 -0
  155. package/session/conversation.js +146 -0
  156. package/session/goal/goal.d.ts +11 -0
  157. package/session/goal/goal.d.ts.map +1 -0
  158. package/session/goal/goal.js +1 -0
  159. package/session/goal/task.d.ts +16 -0
  160. package/session/goal/task.d.ts.map +1 -0
  161. package/session/goal/task.js +3 -0
  162. package/session/session.d.ts +158 -0
  163. package/session/session.d.ts.map +1 -0
  164. package/session/session.js +2 -0
  165. package/src/jobs/check.ts +7 -4
  166. package/src/jobs/context.ts +24 -82
  167. package/src/jobs/permissions/decide-permission.ts +10 -8
  168. package/src/jobs/permissions/find-project-scope.ts +8 -14
  169. package/src/jobs/permissions/match-target.ts +3 -3
  170. package/src/jobs/permissions.ts +8 -6
  171. package/src/jobs/program.ts +13 -4
  172. package/src/jobs/prompt.ts +21 -24
  173. package/src/jobs/schedule.ts +14 -14
  174. package/src/jobs/slash-command.ts +20 -14
  175. package/src/jobs/status-line.ts +9 -21
  176. package/src/jobs/tool.ts +17 -11
  177. package/src/mod.ts +252 -97
  178. package/src/options.ts +22 -22
  179. package/src/records.ts +40 -23
  180. package/src/register.ts +34 -5
  181. package/src/runtime/announce.ts +76 -0
  182. package/src/runtime/before.ts +200 -0
  183. package/src/runtime/bound.ts +12 -0
  184. package/src/runtime/claude.ts +7 -2
  185. package/src/runtime/conversations.ts +579 -0
  186. package/src/runtime/deadline.ts +2 -2
  187. package/src/runtime/dependencies.ts +13 -1
  188. package/src/runtime/engine.ts +23 -0
  189. package/src/runtime/grants.ts +69 -29
  190. package/src/runtime/hooks.ts +39 -14
  191. package/src/runtime/installer.ts +16 -14
  192. package/src/runtime/lifecycle.ts +413 -201
  193. package/src/runtime/metadata.ts +37 -14
  194. package/src/runtime/options.ts +19 -4
  195. package/src/runtime/programs.ts +40 -1
  196. package/src/runtime/router.ts +11 -4
  197. package/src/runtime/sessions.ts +447 -0
  198. package/src/runtime/state.ts +50 -14
  199. package/src/runtime/toaster.ts +334 -0
  200. package/src/runtime/ui.ts +65 -143
  201. package/src/session/context/context.ts +14 -0
  202. package/src/session/context/turn/message/document.ts +8 -0
  203. package/src/session/context/turn/message/message.ts +203 -0
  204. package/src/session/context/turn/message/note.ts +9 -0
  205. package/src/session/context/turn/message/notice.ts +7 -0
  206. package/src/session/context/turn/message/prompt.ts +9 -0
  207. package/src/session/context/turn/message/reply.ts +7 -0
  208. package/src/session/context/turn/message/sender.ts +38 -0
  209. package/src/session/context/turn/message/skill.ts +8 -0
  210. package/src/session/context/turn/message/tool-call.ts +10 -0
  211. package/src/session/context/turn/message/tool-result.ts +9 -0
  212. package/src/session/context/turn/turn.ts +15 -0
  213. package/src/session/conversation.ts +194 -0
  214. package/src/session/goal/goal.ts +10 -0
  215. package/src/session/goal/task.ts +19 -0
  216. package/src/session/session.ts +141 -0
  217. package/src/testing/fake-claude.ts +25 -17
  218. package/src/testing/fake-elements.ts +3 -2
  219. package/src/testing/fake-files.ts +1 -1
  220. package/src/testing/fake-sessions.ts +277 -0
  221. package/src/testing.ts +138 -35
  222. package/src/ui/components.ts +3 -3
  223. package/src/ui/elements.ts +22 -8
  224. package/src/ui/markdown.ts +22 -26
  225. package/src/ui/places.ts +49 -0
  226. package/src/utils/call-effects.ts +5 -5
  227. package/src/utils/changelog.ts +32 -0
  228. package/src/utils/disposable.ts +3 -0
  229. package/src/utils/matcher.ts +37 -0
  230. package/src/utils/metadata.ts +17 -15
  231. package/src/utils/parse-shell.ts +9 -8
  232. package/src/utils/paths.ts +21 -4
  233. package/src/utils/text.ts +4 -0
  234. package/testing/fake-claude.d.ts +5 -1
  235. package/testing/fake-claude.d.ts.map +1 -1
  236. package/testing/fake-claude.js +21 -12
  237. package/testing/fake-elements.js +4 -2
  238. package/testing/fake-files.js +1 -1
  239. package/testing/fake-sessions.d.ts +81 -0
  240. package/testing/fake-sessions.d.ts.map +1 -0
  241. package/testing/fake-sessions.js +249 -0
  242. package/testing.d.ts +34 -13
  243. package/testing.d.ts.map +1 -1
  244. package/testing.js +115 -23
  245. package/ui/components.d.ts +2 -3
  246. package/ui/components.d.ts.map +1 -1
  247. package/ui/components.js +3 -3
  248. package/ui/elements.d.ts.map +1 -1
  249. package/ui/elements.js +19 -9
  250. package/ui/markdown.d.ts +35 -33
  251. package/ui/markdown.d.ts.map +1 -1
  252. package/ui/markdown.js +14 -16
  253. package/ui/places.d.ts +43 -0
  254. package/ui/places.d.ts.map +1 -0
  255. package/ui/places.js +31 -0
  256. package/utils/call-effects.d.ts +2 -1
  257. package/utils/call-effects.d.ts.map +1 -1
  258. package/utils/call-effects.js +3 -3
  259. package/utils/changelog.d.ts +9 -0
  260. package/utils/changelog.d.ts.map +1 -0
  261. package/utils/changelog.js +29 -0
  262. package/utils/disposable.d.ts +4 -0
  263. package/utils/disposable.d.ts.map +1 -0
  264. package/utils/disposable.js +1 -0
  265. package/utils/matcher.d.ts +13 -0
  266. package/utils/matcher.d.ts.map +1 -0
  267. package/utils/matcher.js +32 -0
  268. package/utils/metadata.d.ts +3 -3
  269. package/utils/metadata.js +18 -14
  270. package/utils/parse-shell.js +9 -7
  271. package/utils/paths.d.ts +4 -1
  272. package/utils/paths.d.ts.map +1 -1
  273. package/utils/paths.js +19 -4
  274. package/utils/text.d.ts +1 -0
  275. package/utils/text.d.ts.map +1 -1
  276. package/utils/text.js +3 -0
  277. package/src/ui/slots.ts +0 -36
  278. package/ui/slots.d.ts +0 -208
  279. package/ui/slots.d.ts.map +0 -1
  280. package/ui/slots.js +0 -19
@@ -0,0 +1,579 @@
1
+ import type { Args, EventResult, Frozen } from 'claude-code'
2
+ import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
3
+ import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
4
+ import { Note } from '../session/context/turn/message/note.js'
5
+ import { Person } from '../session/context/turn/message/sender.js'
6
+ import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
7
+ import { Session, UsageChange, type BeforeEvent, type Client, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
8
+ import { GoalChange } from '../session/goal/goal.js'
9
+ import { receivedMatcher } from '../utils/matcher.js'
10
+ import { ifMissing } from '../utils/paths.js'
11
+ import { formatExit, messageOf } from '../utils/text.js'
12
+ import { foldAnswers, installBefore, relayOf, type Place } from './before.js'
13
+ import type { Claude } from './claude.js'
14
+ import { cmodPluginName } from './dependencies.js'
15
+ import { readLines, runCmod } from './programs.js'
16
+ import type { Router } from './router.js'
17
+ import { actPermission, beforePermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, usageOf, type Act, type Subscription } from './sessions.js'
18
+
19
+ type Request = Readonly<Record<string, unknown>>
20
+
21
+ type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
22
+
23
+ type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[] }
24
+
25
+ type Watcher = Watch & { readonly from: string }
26
+
27
+ type Question =
28
+ | { readonly kind: 'act'; readonly caller: string; readonly action: Act; readonly input: unknown; readonly task?: string }
29
+ | { readonly kind: 'before'; readonly mod: string; readonly event: BeforeEvent; readonly change: SessionChange; readonly project: string }
30
+
31
+ type Relay = (Question & { readonly id: string; readonly from: string }) | { readonly kind: 'answer'; readonly id: string; readonly value?: unknown; readonly deny?: string } | { readonly kind: 'watch' } | { readonly kind: 'goal'; readonly task?: string; readonly text: string }
32
+
33
+ type Asked = { resolve(value: unknown): void; reject(error: Error): void }
34
+
35
+
36
+ const bufferedTaskRows = 200
37
+
38
+ const beforeDeadlineMs = 5000
39
+
40
+ const livenessMs = 5000
41
+
42
+
43
+ export type Conversations = {
44
+ answer(e: Frozen<Args<'cmod.call'>> & { readonly caller?: string }): Promise<EventResult<'cmod.call'> | undefined>
45
+ moved(): void
46
+ }
47
+
48
+ export function serveConversations({ claude, router, store, project, report }: { readonly claude: Claude; readonly router: Router; readonly store: string; readonly project: () => string; readonly report: (id: string, message: string, error: unknown) => void }): Conversations {
49
+ const folder = dataFolder(store, cmodPluginName)
50
+ const goals = `${folder}/goals`
51
+ const watches = `${folder}/before`
52
+ const heard = new Map<string, Map<string, readonly Heard[]>>()
53
+ const asked = new Map<string, Asked>()
54
+ const ownWatches = new Map<string, Watch>()
55
+ let watchers: readonly Watcher[] = []
56
+ let watching: Promise<void> = Promise.resolve()
57
+ let published = new Set<string>()
58
+ let publishing: Promise<unknown> = Promise.resolve()
59
+ let session = ''
60
+ let asks = 0
61
+ let conversation: Conversation = createConversation(emptyConversation(''))
62
+ let starting: Promise<void> = Promise.resolve()
63
+ let live: Message[] = []
64
+ const liveIds = new Set<string>()
65
+ const waitingRows = new Map<string, Row[]>()
66
+ let follower: { readonly sessions: string; stop(): void } | undefined
67
+
68
+ const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], session === '' ? await claude.session.id() : session)) as Value
69
+
70
+ const isGranted = async (caller: string | undefined, item: string) => {
71
+ if (caller === cmodPluginName) return true
72
+ if (caller === undefined) return false
73
+ const path = consentPath(store)
74
+ const text = await claude.fs.read(path).catch(ifMissing('{}'))
75
+ return (parseConsent(JSON.parse(text), path)[caller] ?? []).includes(item)
76
+ }
77
+
78
+ const ended = (id: string) => new Error(`session ${id} has ended`)
79
+
80
+ const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
81
+
82
+ const dispatch = (to: string, changes: readonly SessionChange[], isInProject = true) => {
83
+ for (const change of changes) {
84
+ if (change.type === Message && !liveIds.has(change.message.id) && to === session) {
85
+ live.push(change.message)
86
+ liveIds.add(change.message.id)
87
+ }
88
+ for (const [mod, sessions] of heard) {
89
+ const subscriptions = [...(sessions.get(to) ?? []), ...(isInProject ? (sessions.get(everySession) ?? []) : [])]
90
+ if (!subscriptions.some(({ event, matcher }) => isHeardBy(event, matcher, change))) continue
91
+ void claude.cmod.call({ to: mod, method: conversationMethods.changed, input: { session: to, change, isInProject } }).catch((error: unknown) => report(`changed:${mod}`, `Could not tell ${mod} about session ${to}`, error))
92
+ }
93
+ }
94
+ }
95
+
96
+ const restartFollower = async () => {
97
+ const wantsEvery = [...heard.values()].some((sessions) => (sessions.get(everySession) ?? []).length > 0)
98
+ const named = [...heard.values()].flatMap((sessions) => [...sessions].filter(([id, list]) => id !== session && id !== everySession && list.length > 0).map(([id]) => id))
99
+ const others = [...new Set(named)].sort()
100
+ const args = [...others, ...(wantsEvery ? ['--running'] : [])]
101
+ const wanted = [session, project(), ...args].join(' ')
102
+ if (follower?.sessions === wanted) return
103
+ follower?.stop()
104
+ follower = undefined
105
+ if (args.length === 0) return
106
+ const stream = claude.process.spawn({ argv: ['cmod', 'history', 'follow', ...args, '--json', '--project', project()], env: { CLAUDE_CODE_SESSION_ID: session } })
107
+ const started = { sessions: wanted, stop: () => void stream.return(undefined as never) }
108
+ follower = started
109
+ void readLines(stream, (line) => {
110
+ if (line.trim() === '') return
111
+ const parsed = JSON.parse(line) as { readonly session: string; readonly isInProject: boolean; readonly change: SessionChange }
112
+ dispatch(parsed.session, [parsed.change], parsed.isInProject)
113
+ })
114
+ .then(({ code, lastError }) => {
115
+ if (follower === started) throw new Error(`cmod history follow ${formatExit(code, lastError)}`)
116
+ })
117
+ .catch((error: unknown) => {
118
+ if (follower !== started) return
119
+ follower = undefined
120
+ report('follow', 'Stopped hearing the other sessions', error)
121
+ })
122
+ }
123
+
124
+ const follow = () => void restartFollower().catch((error: unknown) => report('follow', 'Stopped hearing the other sessions', error))
125
+
126
+ const post = async (to: string, relay: Relay) => {
127
+ const sent = await claude.session.send({ to: { sessionId: to }, text: `<cmod-relay>${JSON.stringify(relay)}</cmod-relay>` })
128
+ if (sent.isDelivered !== true) throw ended(to)
129
+ }
130
+
131
+ const ask = (to: string, relay: Question, deadlineMs?: number) =>
132
+ new Promise<unknown>((resolve, reject) => {
133
+ const id = `${session}:${(asks += 1)}`
134
+ const timer =
135
+ deadlineMs === undefined
136
+ ? claude.clock.every(livenessMs, () => {
137
+ void isRunning(to).then((isLive) => {
138
+ if (!isLive) settle.reject(ended(to))
139
+ })
140
+ })
141
+ : claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
142
+ const settle: Asked = {
143
+ resolve(value) {
144
+ if (asked.delete(id)) timer.cancel()
145
+ resolve(value)
146
+ },
147
+ reject(error) {
148
+ if (asked.delete(id)) timer.cancel()
149
+ reject(error)
150
+ },
151
+ }
152
+ asked.set(id, settle)
153
+ post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
154
+ })
155
+
156
+ const publishWatches = (isEnding = false) => {
157
+ const next = publishing.then(() => publishNow(isEnding))
158
+ publishing = next.catch((error: unknown) => claude.ui.log(`cmod: publishing before listeners failed: ${messageOf(error)}`, { to: 'debug' }))
159
+ return next
160
+ }
161
+
162
+ const publishNow = async (isEnding: boolean) => {
163
+ if (session === '') return
164
+ const list = isEnding ? [] : [...ownWatches.values()].filter((watch) => watch.events.length > 0)
165
+ await claude.fs.write(`${watches}/${session}.json`, JSON.stringify(list))
166
+ const targets = new Set([...published, ...list.map((watch) => watch.target)])
167
+ published = new Set(list.map((watch) => watch.target))
168
+ const running = targets.has(everySession) ? (await json<SessionData[]>(['history', 'sessions', '--running'])).map((each) => each.id) : []
169
+ const told = new Set([...running, ...targets].filter((id) => id !== everySession && id !== session))
170
+ await Promise.all([...told].map((id) => post(id, { kind: 'watch' }).catch((error: unknown) => claude.ui.log(`cmod: session ${id} did not hear of a before listener: ${messageOf(error)}`, { to: 'debug' }))))
171
+ }
172
+
173
+ const readWatchers = async () => {
174
+ const files = await claude.fs.list(watches).catch(ifMissing([]))
175
+ const found: Watcher[] = []
176
+ for (const file of files) {
177
+ const from = file.name.replace(/\.json$/, '')
178
+ if (from === session || !file.name.endsWith('.json')) continue
179
+ const list = JSON.parse(await claude.fs.read(`${watches}/${file.name}`).catch(ifMissing('[]'))) as readonly Watch[]
180
+ found.push(...list.filter((watch) => watch.target === session || (watch.target === everySession && watch.project === project())).map((watch) => ({ ...watch, from })))
181
+ }
182
+ watchers = found
183
+ }
184
+
185
+ const changeGoal = (task: string | undefined, text: string) => dispatch(session, task === undefined ? conversation.changeGoal(text) : [{ type: GoalChange, goal: { text, task } }])
186
+
187
+ const reloadWatchers = () => {
188
+ watching = readWatchers().catch((error: unknown) => report('before', 'Kept the other sessions’ before listeners from before', error))
189
+ }
190
+
191
+ const place = (): Place => {
192
+ const { context, contextOpenedAt, turn, tasks, taskTurns } = conversation.state
193
+ return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns }
194
+ }
195
+
196
+ const deciders = (event: string): Watcher[] => {
197
+ const all = [...[...ownWatches.values()].filter((watch) => watch.target === session || watch.target === everySession).map((watch) => ({ ...watch, from: session })), ...watchers]
198
+ return [...new Map(all.filter((watcher) => watcher.events.includes(event)).map((watcher) => [`${watcher.from}\n${watcher.mod}`, watcher])).values()]
199
+ }
200
+
201
+ const askHere = async (mod: string, event: BeforeEvent, change: SessionChange, from = session, isInProject = true) => {
202
+ for (const needed of ['conversation', beforePermission(event)]) if (!(await isGranted(mod, needed))) throw new Error(`${mod} needs the ${needed} permission for before(${event}). Turn it on in /mods ${mod}.`)
203
+ return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
204
+ }
205
+
206
+ const askAway = async (watcher: Watcher, event: BeforeEvent, change: SessionChange) => {
207
+ try {
208
+ return await ask(watcher.from, { kind: 'before', mod: watcher.mod, event, change, project: project() }, beforeDeadlineMs)
209
+ } catch (error) {
210
+ if (await isRunning(watcher.from)) throw error
211
+ watchers = watchers.filter((each) => each.from !== watcher.from)
212
+ claude.ui.log(`cmod: session ${watcher.from} ended without clearing its before listeners, so they are cleared now`, { to: 'debug' })
213
+ await claude.fs.write(`${watches}/${watcher.from}.json`, '[]')
214
+ return undefined
215
+ }
216
+ }
217
+
218
+ installBefore({
219
+ claude,
220
+ router,
221
+ hooks: {
222
+ place: async () => {
223
+ await starting
224
+ return place()
225
+ },
226
+ wants: async (event) => {
227
+ await Promise.all([starting, watching])
228
+ return deciders(event).length > 0
229
+ },
230
+ decide: (event, change) =>
231
+ foldAnswers(
232
+ event,
233
+ change,
234
+ deciders(event).map((watcher) => (latest: SessionChange) => (watcher.from === session ? askHere(watcher.mod, event, latest) : askAway(watcher, event, latest))),
235
+ (error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
236
+ ),
237
+ },
238
+ })
239
+
240
+ const start = (id: string, source: string) => {
241
+ if (id === session) return
242
+ const previous = session
243
+ session = id
244
+ live = []
245
+ liveIds.clear()
246
+ waitingRows.clear()
247
+ conversation = createConversation(emptyConversation(id))
248
+ starting =
249
+ source === 'startup' || source === 'clear'
250
+ ? Promise.resolve(dispatch(id, conversation.startContext(new Date().toISOString())))
251
+ : json<ConversationState>(['history', 'state', id]).then(
252
+ (state) => {
253
+ if (session === id) conversation = createConversation({ ...emptyConversation(id), ...state })
254
+ },
255
+ (error: unknown) => report('state', `Starts session ${id} without its earlier turns`, error),
256
+ )
257
+ follow()
258
+ reloadWatchers()
259
+ if (ownWatches.size === 0) return
260
+ void Promise.all([previous === '' ? undefined : claude.fs.write(`${watches}/${previous}.json`, '[]'), publishWatches()]).catch((error: unknown) => report('before', 'Kept its before listeners on the session from before', error))
261
+ }
262
+
263
+ const take = async (row: Row, task: string | undefined) => {
264
+ await starting
265
+ if (task !== undefined && conversation.state.tasks[task] === undefined) {
266
+ const waiting = waitingRows.get(task) ?? []
267
+ waiting.push(row)
268
+ waitingRows.set(task, waiting)
269
+ if (waiting.length < bufferedTaskRows) return
270
+ waitingRows.delete(task)
271
+ dispatch(session, conversation.startTask({ id: task, title: '', agentType: 'general-purpose', openedBy: '', openedAt: row.at }))
272
+ for (const each of waiting) dispatch(session, conversation.add(each, task))
273
+ return
274
+ }
275
+ dispatch(session, conversation.add(row, task))
276
+ }
277
+
278
+ router.add('classic.SessionStart', async (e, next) => {
279
+ start(e.session_id, e.source)
280
+ return next(e)
281
+ })
282
+ router.add('classic.SessionEnd', async (e, next) => {
283
+ if (ownWatches.size > 0) await publishWatches(true).catch((error: unknown) => report('before', 'Left its before listeners registered', error))
284
+ return next(e)
285
+ })
286
+
287
+ router.add('session.append', async (e, next) => {
288
+ const kept = await next(e)
289
+ if (kept.deny !== undefined || session === '') return kept
290
+ const cwd = e.message.type === 'assistant' ? await claude.session.cwd() : ''
291
+ void take(rowOfAppend({ uuid: kept.uuid, door: e.door, origin: e.origin, message: kept.message }, new Date().toISOString(), cwd), e.agentId).catch((error: unknown) => report('append', 'Missed a message', error))
292
+ return kept
293
+ })
294
+
295
+ router.add('session.compact', async (e, next) => {
296
+ const compacted = await next(e)
297
+ if (e.trigger === 'precompute' || compacted.skip !== undefined || session === '') return compacted
298
+ const at = new Date().toISOString()
299
+ const boundary: Row = { id: `compact:${e.agentId ?? session}:${at}`, at, type: 'system', name: 'compact_boundary', isMeta: false, isSummary: false, content: [] }
300
+ void take(boundary, e.agentId).catch((error: unknown) => report('compact', 'Missed a compaction', error))
301
+ return compacted
302
+ })
303
+
304
+ router.add('session.measure', async (e, next) => {
305
+ const measured = await next(e)
306
+ if (session !== '') dispatch(session, [{ type: UsageChange, usage: await usageOf(claude) }])
307
+ return measured
308
+ })
309
+
310
+ router.add('agent.spawn', async (e, next) => {
311
+ const spawned = await next(e)
312
+ if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
313
+ const id = spawned.agentId
314
+ const opened: TaskData = { id, title: e.description, agentType: e.subagentType, model: spawned.model, openedBy: e.tool_use_id, openedAt: new Date().toISOString(), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }) }
315
+ await starting
316
+ dispatch(session, conversation.startTask(opened))
317
+ const waiting = waitingRows.get(id) ?? []
318
+ waitingRows.delete(id)
319
+ for (const row of waiting) dispatch(session, conversation.add(row, id))
320
+ return spawned
321
+ })
322
+
323
+ router.add('session.receive', async (e, next) => {
324
+ const sent = relayOf(e)
325
+ if (sent === undefined) return next(e)
326
+ const relay = JSON.parse(sent) as Relay
327
+ claude.clock.after(0, () => void received(relay).catch((error: unknown) => report('relay', 'Could not answer another session', error)))
328
+ return { consumed: 'cmod relay' }
329
+ })
330
+
331
+ const received = async (relay: Relay) => {
332
+ if (relay.kind === 'answer') {
333
+ const waiting = asked.get(relay.id)
334
+ if (relay.deny === undefined) waiting?.resolve(relay.value)
335
+ else waiting?.reject(new Error(relay.deny))
336
+ return
337
+ }
338
+ if (relay.kind === 'watch') return readWatchers()
339
+ if (relay.kind === 'goal') {
340
+ await starting
341
+ changeGoal(relay.task, relay.text)
342
+ return
343
+ }
344
+ const answered = async (run: () => Promise<unknown>) => {
345
+ try {
346
+ await post(relay.from, { kind: 'answer', id: relay.id, value: (await run()) ?? null })
347
+ } catch (error) {
348
+ await post(relay.from, { kind: 'answer', id: relay.id, deny: messageOf(error) })
349
+ }
350
+ }
351
+ if (relay.kind === 'act') return answered(() => perform(relay.caller, relay.action, relay.input, relay.task))
352
+ return answered(() => askHere(relay.mod, relay.event, relay.change, relay.from, relay.project === project()))
353
+ }
354
+
355
+ const checkAct = async (caller: string, action: Act, input: unknown) => {
356
+ const needed = actPermission(action, input)
357
+ if (needed !== undefined && !(await isGranted(caller, needed))) throw new Error(`${caller} needs the ${needed} permission for ${action}. Turn it on in /mods ${caller}.`)
358
+ }
359
+
360
+ const perform = async (caller: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
361
+ await checkAct(caller, action, input)
362
+ await starting
363
+ if (task !== undefined) return performInTask(action, input, task)
364
+ if (action === 'usage') return usageOf(claude)
365
+ if (action === 'clients') return [...(await claude.session.surfaces())] satisfies Client[]
366
+ if (action === 'send') {
367
+ const { type, text, as } = input as SessionSend
368
+ if (as === Session) throw new Error(`session ${session} cannot send as itself to itself.`)
369
+ const delivered = type === Note ? await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }) : await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
370
+ if ('deny' in delivered && typeof delivered.deny === 'string') throw new Error(`Claude Code refused the note: ${delivered.deny}`)
371
+ if ('drop' in delivered && typeof delivered.drop === 'string') throw new Error(`Claude Code dropped the prompt: ${delivered.drop}`)
372
+ return null
373
+ }
374
+ if (action === 'startTask') {
375
+ const args = input as TaskStartArgs
376
+ const started = await claude.agent.spawn({
377
+ prompt: args.prompt,
378
+ ...(args.name === undefined ? {} : { description: args.name, name: args.name }),
379
+ ...(args.agentType === undefined ? {} : { subagentType: args.agentType }),
380
+ ...(args.model === undefined ? {} : { model: args.model }),
381
+ ...(args.cwd === undefined ? {} : { cwd: args.cwd }),
382
+ })
383
+ if (started.deny !== undefined) throw new Error(`Claude Code refused the task: ${started.deny}`)
384
+ return { id: started.agentId ?? '', title: args.name ?? '', agentType: args.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() } satisfies TaskData
385
+ }
386
+ const { name, input: toolInput } = input as ToolCallArgs
387
+ return resultOf(place(), await claude.tool.call({ ...toolInput, tool: name } as never))
388
+ }
389
+
390
+ const performInTask = async (action: Act, input: unknown, task: string): Promise<unknown> => {
391
+ const running = conversation.state.tasks[task]
392
+ if (action === 'usage') return json(['history', 'usage', session, '--task', task])
393
+ if (action === 'clients') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
394
+ if (action !== 'send') throw new Error(`cmod: ${action} runs only on a session's own main loop.`)
395
+ if (running === undefined || running.closedAt !== undefined) throw ended(task)
396
+ const sent = await claude.session.send({ to: { agentId: task }, text: (input as SessionSend).text })
397
+ if (sent.isDelivered !== true) throw ended(task)
398
+ return null
399
+ }
400
+
401
+ const actElsewhere = async (caller: string, target: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
402
+ await checkAct(caller, action, input)
403
+ const isLive = await isRunning(target)
404
+ if (!isLive && action === 'usage') return json(['history', 'usage', target, ...(task === undefined ? [] : ['--task', task])])
405
+ if (!isLive && action === 'clients') return []
406
+ if (!isLive) throw ended(task ?? target)
407
+ if (action === 'send' && task === undefined && (input as SessionSend).as === Session) {
408
+ const sent = await claude.session.send({ to: { sessionId: target }, text: (input as SessionSend).text })
409
+ if (sent.isDelivered !== true) throw ended(target)
410
+ return null
411
+ }
412
+ return ask(target, { kind: 'act', caller, action, input, ...(task === undefined ? {} : { task }) })
413
+ }
414
+
415
+ const merged = <Item>(saved: readonly Item[], kept: readonly Item[], key: (item: Item) => string): Item[] => {
416
+ const keptKeys = new Set(kept.map(key))
417
+ return [...saved.filter((item) => !keptKeys.has(key(item))), ...kept]
418
+ }
419
+
420
+ const liveTurns = (task: unknown): TurnData[] => {
421
+ const turn = typeof task === 'string' ? conversation.state.taskTurns[task] : conversation.state.turn
422
+ return turn === undefined ? [] : [turn]
423
+ }
424
+
425
+ const liveTasks = (): TaskData[] =>
426
+ Object.values(conversation.state.tasks).map((task) => ({
427
+ id: task.id,
428
+ title: task.title,
429
+ agentType: task.agentType,
430
+ ...(task.model === undefined ? {} : { model: task.model }),
431
+ openedBy: task.openedBy,
432
+ openedAt: task.openedAt,
433
+ ...(task.closedAt === undefined ? {} : { closedAt: task.closedAt }),
434
+ ...(task.parent === undefined ? {} : { parent: task.parent }),
435
+ }))
436
+
437
+ const liveContext = (task: unknown): ContextData[] => {
438
+ if (typeof task === 'string') {
439
+ const running = conversation.state.tasks[task]
440
+ return running === undefined ? [] : [{ number: running.context, openedAt: running.contextOpenedAt ?? running.openedAt, task, ...(running.closedAt === undefined ? {} : { closedAt: running.closedAt }) }]
441
+ }
442
+ const { context, contextOpenedAt } = conversation.state
443
+ return context === 0 ? [] : [{ number: context, openedAt: contextOpenedAt }]
444
+ }
445
+
446
+ const lastOf = <Item>(items: readonly Item[], last: unknown): Item[] => (typeof last === 'number' ? items.slice(-last) : [...items])
447
+
448
+ const methods: Readonly<Record<string, (request: Request, caller: string | undefined) => Promise<unknown>>> = {
449
+ async [conversationMethods.sessions](request) {
450
+ const found = await json<SessionData[]>(['history', 'sessions', ...scopeArgs(request), ...(request['running'] === true ? ['--running'] : [])])
451
+ return found.map((each) => (each.id === session ? { ...each, isRunning: true } : each))
452
+ },
453
+ async [conversationMethods.search](request) {
454
+ const before = isObject(request['before']) ? request['before'] : undefined
455
+ const record = [...textArg('--session', request['session']), ...textArg('--task', request['task']), ...textArg('--turn', request['turn']), ...countArg('--context', request['context']), ...(request['isGoal'] === true ? ['--goal'] : [])]
456
+ return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...record, ...(request['running'] === true ? ['--running'] : []), ...countArg('--limit', request['limit']), ...(before === undefined ? [] : ['--before', `${String(before['session'])}:${String(before['id'])}`])])
457
+ },
458
+ async [conversationMethods.messages](request) {
459
+ const id = String(request['session'])
460
+ const filters = [...textArg('--turn', request['turn']), ...textArg('--task', request['task']), ...textArg('--after', request['after'])]
461
+ const saved = await json<Message[]>(['history', 'messages', id, ...filters, ...countArg('--last', request['last'])])
462
+ if (id !== session) return saved
463
+ await starting
464
+ const fits = (message: Message) => (request['turn'] === undefined || message.turn === request['turn']) && message.task === request['task']
465
+ const after = typeof request['after'] === 'string' ? live.findIndex((message) => message.id === request['after']) : -1
466
+ const kept = live.slice(after + 1).filter(fits)
467
+ return lastOf(merged(saved, kept, (message) => message.id), request['last'])
468
+ },
469
+ async [conversationMethods.turns](request) {
470
+ const id = String(request['session'])
471
+ const saved = await json<TurnData[]>(['history', 'turns', id, ...textArg('--task', request['task']), ...countArg('--context', request['context']), ...countArg('--last', request['last'])])
472
+ if (id !== session) return saved
473
+ await starting
474
+ const kept = liveTurns(request['task']).filter((turn) => request['context'] === undefined || turn.context === request['context'])
475
+ return lastOf(merged(saved, kept, (turn) => turn.prompt.id), request['last'])
476
+ },
477
+ async [conversationMethods.contexts](request) {
478
+ const id = String(request['session'])
479
+ const saved = await json<ContextData[]>(['history', 'contexts', id, ...textArg('--task', request['task'])]).catch((error: unknown) => {
480
+ if (id === session && typeof request['task'] === 'string') return []
481
+ throw error
482
+ })
483
+ if (id !== session) return saved
484
+ await starting
485
+ return merged(saved, liveContext(request['task']), (context) => String(context.number))
486
+ },
487
+ async [conversationMethods.tasks](request) {
488
+ const id = String(request['session'])
489
+ const isNested = request['all'] === true
490
+ const saved = await json<TaskData[]>(['history', 'tasks', id, ...(isNested ? ['--nested'] : textArg('--task', request['task']))])
491
+ if (id !== session) return saved
492
+ await starting
493
+ const kept = liveTasks().filter((task) => isNested || task.parent === request['task'])
494
+ return merged(saved, kept, (task) => task.id)
495
+ },
496
+ async [conversationMethods.goal](request) {
497
+ const id = String(request['session'])
498
+ const task = typeof request['task'] === 'string' ? request['task'] : undefined
499
+ if (id === session && task === undefined) {
500
+ await starting
501
+ const text = conversation.state.goal
502
+ if (text !== undefined) return { text }
503
+ }
504
+ return json(['history', 'goal', id, ...textArg('--task', task)])
505
+ },
506
+ async [conversationMethods.updateGoal](request) {
507
+ const id = String(request['session'])
508
+ const task = typeof request['task'] === 'string' ? request['task'] : undefined
509
+ const text = String(request['text'])
510
+ await claude.fs.write(`${goals}/${task ?? id}.txt`, text)
511
+ await starting
512
+ if (id === session) changeGoal(task, text)
513
+ else {
514
+ dispatch(id, [{ type: GoalChange, goal: { text, ...(task === undefined ? {} : { task }) } }])
515
+ await post(id, { kind: 'goal', ...(task === undefined ? {} : { task }), text }).catch((error: unknown) => claude.ui.log(`cmod: session ${id} keeps its goal until it starts again: ${messageOf(error)}`, { to: 'debug' }))
516
+ }
517
+ return null
518
+ },
519
+ async [conversationMethods.subscribe](request, caller) {
520
+ if (caller === undefined) throw new Error('cmod hears no caller for this subscription.')
521
+ const id = String(request['session'])
522
+ const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
523
+ const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
524
+ sessions.set(id, list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
525
+ heard.set(caller, sessions)
526
+ follow()
527
+ return null
528
+ },
529
+ async [conversationMethods.watchBefore](request, caller) {
530
+ if (caller === undefined) throw new Error('cmod hears no caller for this before listener.')
531
+ const target = String(request['session'])
532
+ const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
533
+ ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))] })
534
+ await publishWatches()
535
+ return null
536
+ },
537
+ async [conversationMethods.act](request, caller) {
538
+ if (caller === undefined) throw new Error('cmod hears no caller for this action.')
539
+ const target = String(request['session'])
540
+ const action = String(request['action']) as Act
541
+ const task = typeof request['task'] === 'string' ? request['task'] : undefined
542
+ if (!isAct(action)) throw new Error(`cmod takes no action named ${action}.`)
543
+ if (target === session) return perform(caller, action, request['input'], task)
544
+ return actElsewhere(caller, target, action, request['input'], task)
545
+ },
546
+ }
547
+
548
+ return {
549
+ moved() {
550
+ for (const [key, watch] of ownWatches) ownWatches.set(key, { ...watch, project: project() })
551
+ void publishWatches().catch((error: unknown) => report('before', 'Kept its before listeners on the project from before', error))
552
+ reloadWatchers()
553
+ follow()
554
+ },
555
+ async answer(e) {
556
+ const method = methods[e.method]
557
+ if (method === undefined) return undefined
558
+ const input = isObject(e.input) ? e.input : {}
559
+ if (e.method !== conversationMethods.act && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
560
+ try {
561
+ return { value: await method(input, e.caller) }
562
+ } catch (error) {
563
+ return { deny: messageOf(error) }
564
+ }
565
+ },
566
+ }
567
+ }
568
+
569
+ function scopeArgs(request: Request): string[] {
570
+ return [...(typeof request['since'] === 'string' ? ['--since', request['since']] : []), ...(request['everyProject'] === true ? ['--all'] : [])]
571
+ }
572
+
573
+ function countArg(flag: string, value: unknown): string[] {
574
+ return typeof value === 'number' ? [flag, String(value)] : []
575
+ }
576
+
577
+ function textArg(flag: string, value: unknown): string[] {
578
+ return typeof value === 'string' ? [flag, value] : []
579
+ }
@@ -1,10 +1,10 @@
1
1
  import type { Claude } from './claude.js'
2
2
 
3
- export type Deadline = { readonly ms: number; readonly job?: string; readonly longestMs?: number }
3
+ export type Deadline = { readonly ms: number; readonly job?: string }
4
4
 
5
5
  export const longestMs = 600_000
6
6
 
7
- export const toolDeadline: Deadline = { ms: 30000, longestMs, job: 'tool' }
7
+ export const toolDeadline: Deadline = { ms: 30000, job: 'tool' }
8
8
 
9
9
  export function beforeDeadline<Value>(claude: Claude, { ms, job }: Deadline, call: string, task: Promise<Value>): Promise<Value> {
10
10
  return new Promise<Value>((resolve, reject) => {
@@ -2,6 +2,8 @@ import type { Args, CmodDependencies, EventResult, Frozen } from 'claude-code'
2
2
  import { messageOf } from '../utils/text.js'
3
3
  import type { Claude } from './claude.js'
4
4
 
5
+ export const cmodPluginName = 'cmod'
6
+
5
7
  export async function answerCall(name: string, api: { readonly [method: string]: (input: never) => unknown }, e: Frozen<Args<'cmod.call'>>): Promise<EventResult<'cmod.call'>> {
6
8
  const method = Object.hasOwn(api, e.method) ? api[e.method] : undefined
7
9
  if (method === undefined) return { deny: `${name} has no method ${e.method}.` }
@@ -21,6 +23,16 @@ export function dependencyCalls(claude: Claude, within: (call: string, task: Pro
21
23
  return new Proxy({}, { get: (_dependencies, to) => (typeof to === 'string' && to !== 'then' ? methodsOf(to) : undefined) }) as CmodDependencies
22
24
  }
23
25
 
26
+ export function withBareRefusals(claude: Claude): Claude {
27
+ const prefix = `${claude.plugin.name}: $.cmod.call: `
28
+ const call: Claude['cmod']['call'] = (input) =>
29
+ claude.cmod.call(input).catch((error: unknown) => {
30
+ const message = messageOf(error)
31
+ throw message.startsWith(prefix) ? new Error(message.slice(prefix.length), { cause: error }) : error
32
+ })
33
+ return { ...claude, cmod: { call } }
34
+ }
35
+
24
36
  export function notInstalled(name: string): string {
25
- return `${name} is not installed. Run cmod install ${name}.`
37
+ return `${name} is not installed. Add it with /plugin.`
26
38
  }
@@ -0,0 +1,23 @@
1
+ import type { Claude } from './claude.js'
2
+ import type { Router } from './router.js'
3
+ import type { Failure } from './toaster.js'
4
+ import type { ToolCalls } from './tool-calls.js'
5
+
6
+ export type Engine = {
7
+ readonly claude: Claude
8
+ readonly router: Router
9
+ readonly toolCalls: ToolCalls
10
+ readonly report: (failure: Failure) => void
11
+ }
12
+
13
+ const engines = new WeakMap<object, Engine>()
14
+
15
+ export function keepEngine(mod: object, engine: Engine): void {
16
+ engines.set(mod, engine)
17
+ }
18
+
19
+ export function engineOf(mod: object): Engine {
20
+ const engine = engines.get(mod)
21
+ if (engine === undefined) throw new Error('This mod has not started, so it reaches no Claude Code calls yet.')
22
+ return engine
23
+ }