@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
@@ -1,7 +1,9 @@
1
- import { permissionWords } from '../records.js'
1
+ import { isObject, permissionWords } from '../records.js'
2
2
  import { relativePath } from '../utils/paths.js'
3
3
  import type { Claude } from './claude.js'
4
+ import { cmodPluginName } from './dependencies.js'
4
5
  import type { RoutedEvent } from './hooks.js'
6
+ import { actPermission, conversationMethods, isAct, toolPermission } from './sessions.js'
5
7
 
6
8
  export type Grants = {
7
9
  readonly name: string
@@ -12,24 +14,47 @@ export type Grants = {
12
14
  readonly configRoot: string | undefined
13
15
  readonly projectRoot: () => string
14
16
  readonly freeFolders: () => readonly string[]
17
+ readonly freeAddresses: () => ReadonlySet<string>
15
18
  }
16
19
 
17
20
  export class PermissionRefused extends Error {}
18
21
 
19
- const configFiles: readonly string[] = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json']
22
+ const addressesOfPrograms = new WeakMap<object, Set<string>>()
20
23
 
21
- const startsTurns: readonly string[] = ['CronCreate']
24
+ export function programAddresses(mod: object): Set<string> {
25
+ const known = addressesOfPrograms.get(mod) ?? new Set<string>()
26
+ addressesOfPrograms.set(mod, known)
27
+ return known
28
+ }
22
29
 
23
- const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
30
+ const projectSettingsFiles: readonly string[] = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json']
31
+
32
+ const conversationCalls: Readonly<Record<string, string>> = {
33
+ [conversationMethods.sessions]: 'sessions.list',
34
+ [conversationMethods.search]: 'sessions.search',
35
+ [conversationMethods.messages]: 'messages',
36
+ [conversationMethods.turns]: 'turns',
37
+ [conversationMethods.contexts]: 'contexts',
38
+ [conversationMethods.tasks]: 'tasks',
39
+ [conversationMethods.goal]: 'goal',
40
+ [conversationMethods.updateGoal]: 'goal.update',
41
+ [conversationMethods.subscribe]: 'on',
42
+ [conversationMethods.watchBefore]: 'before',
43
+ [conversationMethods.act]: 'act',
44
+ }
24
45
 
25
- async function checkGrant(grants: Grants, call: string, item: string | undefined): Promise<void> {
46
+ export async function checkGrant(grants: Grants, call: string, item: string | undefined): Promise<void> {
26
47
  if (item === undefined) return
27
48
  await grants.refresh()
28
49
  if (!isCovered(grants.granted(), item, grants.home)) throw new PermissionRefused(refusal(grants, call, item))
29
50
  }
30
51
 
31
- export function checkWrite(grants: Grants, call: string, path: string): Promise<void> {
32
- return checkGrant(grants, call, writeItem(grants, path))
52
+ export async function checkWrite(grants: Grants, call: string, path: string): Promise<void> {
53
+ await checkGrant(grants, call, writeItem(grants, path))
54
+ }
55
+
56
+ export async function checkSettings(grants: Grants, source: 'user' | 'project' | 'local', keys: readonly string[]): Promise<void> {
57
+ for (const key of keys) await checkGrant(grants, `settings.${source}.update(${key})`, `config:${key}`)
33
58
  }
34
59
 
35
60
  export function checkingGrants(claude: Claude, grants: Grants): Claude {
@@ -59,7 +84,7 @@ export function checkingGrants(claude: Claude, grants: Grants): Claude {
59
84
  },
60
85
  http: {
61
86
  async fetch(url, init) {
62
- await gate(`http.fetch(${url})`, `network:${init?.socketPath ?? hostOf(url)}`)
87
+ if (!grants.freeAddresses().has(init?.socketPath ?? addressOf(url))) await gate(`http.fetch(${url})`, `network:${init?.socketPath ?? hostOf(url)}`)
63
88
  return claude.http.fetch(url, init)
64
89
  },
65
90
  },
@@ -69,37 +94,49 @@ export function checkingGrants(claude: Claude, grants: Grants): Claude {
69
94
  await gate('session.messages', 'conversation')
70
95
  return claude.session.messages(...args)
71
96
  }) as Claude['session']['messages'],
72
- append: (async (args: Parameters<Claude['session']['append']>[0]) => {
73
- await gate('session.append', 'prompt')
97
+ append: async (args: Parameters<Claude['session']['append']>[0]) => {
98
+ await gate('send', 'prompt')
74
99
  return claude.session.append(args)
75
- }) as Claude['session']['append'],
100
+ },
101
+ send: async (args: Parameters<Claude['session']['send']>[0]) => {
102
+ await gate('send', 'prompt')
103
+ return claude.session.send(args)
104
+ },
76
105
  },
77
106
  prompt: {
78
- submit: (async (args: Parameters<Claude['prompt']['submit']>[0]) => {
79
- await gate('session.submit', 'prompt')
107
+ submit: async (args: Parameters<Claude['prompt']['submit']>[0]) => {
108
+ await gate('send', 'prompt')
80
109
  return claude.prompt.submit(args)
81
- }) as Claude['prompt']['submit'],
110
+ },
111
+ },
112
+ cmod: {
113
+ async call(args) {
114
+ const named = args.to === cmodPluginName ? conversationCalls[args.method] : undefined
115
+ const given = args.method === conversationMethods.act && isObject(args.input) ? String(args.input['action']) : undefined
116
+ const action = given !== undefined && isAct(given) ? given : undefined
117
+ if (named !== undefined) await gate(action ?? named, action === undefined ? 'conversation' : actPermission(action, isObject(args.input) ? args.input['input'] : undefined))
118
+ return claude.cmod.call(args)
119
+ },
82
120
  },
83
121
  model: {
84
- complete: (async (...args: Parameters<Claude['model']['complete']>) => {
122
+ complete: async (...args: Parameters<Claude['model']['complete']>) => {
85
123
  await gate('model.complete', 'model')
86
124
  return claude.model.complete(...args)
87
- }) as Claude['model']['complete'],
125
+ },
88
126
  },
89
127
  agent: {
90
128
  ...claude.agent,
91
129
  async spawn(args) {
92
- await gate('agent.spawn', 'agents')
130
+ await gate('startTask', 'tasks')
93
131
  return claude.agent.spawn(args)
94
132
  },
95
133
  },
96
134
  tool: {
97
135
  ...claude.tool,
98
- call: (async (input: Parameters<Claude['tool']['call']>[0]) => {
99
- const item = startsTurns.includes(input.tool) ? 'prompt' : ownSchedule.includes(input.tool) ? undefined : 'tools'
100
- await gate(`tool.call(${input.tool})`, item)
136
+ call: async (input: Parameters<Claude['tool']['call']>[0]) => {
137
+ await gate(`tool.call(${input.tool})`, toolPermission(input.tool))
101
138
  return claude.tool.call(input)
102
- }) as Claude['tool']['call'],
139
+ },
103
140
  },
104
141
  }
105
142
  }
@@ -112,8 +149,8 @@ function spawned(started: Promise<Stream>): Stream {
112
149
  const stream = {
113
150
  next: async () => (await started).next(),
114
151
  async return(value: never) {
115
- const running = await started.catch(() => undefined)
116
- return (await running?.return?.(value)) ?? { done: true as const, value }
152
+ const [start] = await Promise.allSettled([started])
153
+ return start.status === 'fulfilled' ? start.value.return(value) : { done: true as const, value }
117
154
  },
118
155
  [Symbol.asyncIterator]: () => stream,
119
156
  result,
@@ -144,7 +181,10 @@ function readItem(grants: Grants, path: string): string | undefined {
144
181
 
145
182
  function writeItem(grants: Grants, path: string): string | undefined {
146
183
  const root = grants.projectRoot()
147
- if (configFiles.some((file) => `${root}/${file}` === path)) return 'config'
184
+ const settingsFiles = [...projectSettingsFiles.map((file) => `${root}/${file}`), ...(grants.configRoot === undefined ? [] : [`${grants.configRoot}/settings.json`])]
185
+ if (settingsFiles.includes(path)) {
186
+ throw new PermissionRefused(`${grants.name} writes ${path} by hand. Change Claude Code's settings with settings.user.update, settings.project.update, or settings.local.update, which keep the person's own edits and take the mod's values out when it is removed.`)
187
+ }
148
188
  if (grants.freeFolders().some((folder) => relativePath(folder, path) !== undefined)) return undefined
149
189
  return `files:${path}`
150
190
  }
@@ -287,11 +327,11 @@ function programOf(argv: readonly string[]): string {
287
327
  }
288
328
 
289
329
  function hostOf(url: string): string {
290
- try {
291
- return new URL(url).hostname
292
- } catch {
293
- return url
294
- }
330
+ return URL.canParse(url) ? new URL(url).hostname : url
331
+ }
332
+
333
+ function addressOf(url: string): string {
334
+ return URL.canParse(url) ? new URL(url).host : url
295
335
  }
296
336
 
297
337
  function expanded(path: string, home: string | undefined): string {
@@ -1,19 +1,22 @@
1
1
  import type { Args, ClassicHookInputs, EventResult, Frozen } from 'claude-code'
2
- import type { HookAnswer, HookInput, ModEvent, ModHook } from '../mod.js'
2
+ import type { Hook, HookAnswer, HookInput, HookListener } from '../mod.js'
3
3
  import { configFolders } from '../records.js'
4
4
  import type { Claude } from './claude.js'
5
5
  import type { RoutedHook } from './router.js'
6
+ import type { Failure } from './toaster.js'
6
7
  import { reservedKeys, toolInputOf, type ToolCalls } from './tool-calls.js'
7
8
  import { callEffects, inputOf, type CallBase, type FileAccess } from '../utils/call-effects.js'
8
9
  import { dynamicPattern } from '../utils/parse-shell.js'
10
+ import { isMissing } from '../utils/paths.js'
9
11
  import { messageOf } from '../utils/text.js'
10
12
 
11
13
  export type RoutedEvent =
12
- | `classic.${Exclude<ModEvent, 'PreToolUse'>}`
14
+ | `classic.${Exclude<Hook, 'PreToolUse'>}`
13
15
  | 'tool.check'
14
16
  | 'tool.call'
15
17
  | 'prompt.submit'
16
18
  | 'prompt.context'
19
+ | 'prompt.attachment'
17
20
  | 'command.run'
18
21
  | 'session.measure'
19
22
  | 'skill.prompt'
@@ -22,6 +25,10 @@ export type RoutedEvent =
22
25
  | 'ui.scroll'
23
26
  | 'ui.close'
24
27
  | 'cmod.call'
28
+ | 'session.append'
29
+ | 'session.compact'
30
+ | 'session.receive'
31
+ | 'agent.spawn'
25
32
 
26
33
  export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
27
34
 
@@ -31,28 +38,43 @@ type CallFiles = HookInput<'PreToolUse'>['files']
31
38
 
32
39
  const flags: readonly string[] = ['suppressOriginalPrompt', 'reloadSkills', 'retry']
33
40
 
34
- const readFields: Record<ModEvent, readonly string[]> = {
41
+ const readFields: Record<Hook, readonly string[]> = {
42
+ Setup: ['additionalContext'],
35
43
  SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
36
44
  SessionEnd: [],
37
45
  UserPromptSubmit: ['additionalContext', 'sessionTitle', 'suppressOriginalPrompt'],
46
+ UserPromptExpansion: ['additionalContext', 'suppressOriginalPrompt'],
38
47
  InstructionsLoaded: [],
48
+ ConfigChange: [],
49
+ DirectoryAdded: [],
50
+ PreModelSwitch: ['permissionDecision', 'permissionDecisionReason'],
51
+ PostModelSwitch: ['additionalContext'],
39
52
  PreToolUse: ['additionalContext', 'permissionDecision', 'permissionDecisionReason', 'updatedInput'],
40
53
  PermissionRequest: ['decision'],
41
54
  PermissionDenied: ['retry'],
42
55
  PostToolUse: ['additionalContext', 'updatedToolOutput', 'updatedMCPToolOutput'],
43
56
  PostToolUseFailure: ['additionalContext'],
44
57
  PostToolBatch: ['additionalContext'],
58
+ MessageDisplay: ['displayContent'],
45
59
  SubagentStart: ['additionalContext'],
46
60
  SubagentStop: ['additionalContext'],
61
+ TaskCreated: [],
62
+ TaskCompleted: [],
63
+ TeammateIdle: [],
64
+ Elicitation: [],
65
+ ElicitationResult: [],
47
66
  Notification: [],
48
67
  PreCompact: [],
68
+ PostCompact: [],
49
69
  Stop: ['additionalContext'],
50
70
  StopFailure: [],
51
71
  CwdChanged: [],
52
72
  FileChanged: [],
73
+ WorktreeCreate: ['worktreePath'],
74
+ WorktreeRemove: [],
53
75
  }
54
76
 
55
- export function classicHook<E extends Exclude<ModEvent, 'PreToolUse'>>(name: string, event: E, hook: ModHook<E>, claude: Claude, calls: ToolCalls): RoutedHook<RoutedEvent> {
77
+ export function classicHook<E extends Exclude<Hook, 'PreToolUse'>>(name: string, event: E, hook: HookListener<E>, claude: Claude, calls: ToolCalls, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
56
78
  const hookInputOf = async (e: unknown): Promise<unknown> => {
57
79
  if (event !== 'PostToolUse' && event !== 'PostToolUseFailure') return e
58
80
  const input = e as ClassicHookInputs['PostToolUse' | 'PostToolUseFailure']
@@ -67,9 +89,8 @@ export function classicHook<E extends Exclude<ModEvent, 'PreToolUse'>>(name: str
67
89
  }
68
90
  const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
69
91
  const ours = await resultOf(e).catch((error: unknown): ClassicFields | undefined => {
70
- const reason = `${name}: the ${event} hook failed: ${messageOf(error)}`
71
- if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: reason } }
72
- claude.ui.log(reason)
92
+ if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: `${name}: the ${event} hook failed: ${messageOf(error)}` } }
93
+ report({ id: `hook:${event}`, message: `The ${event} hook failed`, error })
73
94
  return undefined
74
95
  })
75
96
  if (ours === undefined) return next(e)
@@ -82,7 +103,7 @@ type HeldDecision = { readonly decision: 'allow' | 'ask'; readonly reason?: stri
82
103
 
83
104
  export type HeldDecisions = Map<string, HeldDecision>
84
105
 
85
- export function preToolUseHook(name: string, hook: ModHook<'PreToolUse'>, claude: Claude, calls: ToolCalls, held: HeldDecisions | undefined): RoutedHook<'tool.call'> {
106
+ export function preToolUseHook(name: string, hook: HookListener<'PreToolUse'>, claude: Claude, calls: ToolCalls, held: HeldDecisions | undefined): RoutedHook<'tool.call'> {
86
107
  return async (e, next) => {
87
108
  let ours: ClassicFields
88
109
  try {
@@ -167,11 +188,15 @@ async function preToolUseInput(name: string, envelope: Frozen<Args<'tool.call'>>
167
188
 
168
189
  async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
169
190
  try {
170
- const home = await claude.env.home()
191
+ const [home, configRoot] = await Promise.all([claude.env.home(), claude.env.configHome()])
171
192
  if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
172
- const { shell, reads, writes } = callEffects(use, { cwd, home, fs: claude.fs })
193
+ const { shell, reads, writes } = callEffects(use, { cwd, home, ...(configRoot === undefined ? {} : { configRoot }), fs: claude.fs })
173
194
  const known = (accesses: FileAccess[]) => accesses.map(({ path }) => path).filter((path) => shell === undefined || !dynamicPattern.test(path))
174
- const read = await Promise.all(known(reads).map(async (path) => ((await claude.fs.stat(path).catch(() => undefined))?.kind === 'file' ? [path] : [])))
195
+ const unchecked = (path: string) => (error: unknown) => {
196
+ if (!isMissing(error)) claude.ui.log(`${name}: the ${use.tool} call reads ${path}, which could not be checked: ${messageOf(error)}`, { to: 'debug' })
197
+ return undefined
198
+ }
199
+ const read = await Promise.all(known(reads).map(async (path) => ((await claude.fs.stat(path).catch(unchecked(path)))?.kind === 'file' ? [path] : [])))
175
200
  return { read: read.flat(), changed: known(writes) }
176
201
  } catch (error) {
177
202
  claude.ui.log(`${name}: the ${use.tool} call lists no files: ${messageOf(error)}`, { to: 'debug' })
@@ -179,7 +204,7 @@ async function callFiles(name: string, claude: Claude, use: CallBase, cwd: strin
179
204
  }
180
205
  }
181
206
 
182
- function classicResult(event: ModEvent, answer: HookAnswer): ClassicFields {
207
+ function classicResult(event: Hook, answer: HookAnswer): ClassicFields {
183
208
  const result: ClassicFields = {}
184
209
  const unread = (field: string) => new Error(`it answered ${field}, which ${event} does not read. Remove it from the answer.`)
185
210
  if (event === 'PreToolUse') {
@@ -198,10 +223,10 @@ function classicResult(event: ModEvent, answer: HookAnswer): ClassicFields {
198
223
  if (field === 'hookEventName' || value === undefined || (value === false && flags.includes(field))) continue
199
224
  if (!readFields[event].includes(field)) throw unread(`hookSpecificOutput.${field}`)
200
225
  if (field === 'additionalContext') result['additionalContext'] = [value]
201
- else if (field === 'permissionDecision') {
226
+ else if (field === 'permissionDecision' && event === 'PreToolUse') {
202
227
  const reason = specific.permissionDecisionReason ?? ''
203
228
  result[value as string] = value === 'allow' ? true : reason
204
- } else if (field !== 'permissionDecisionReason') result[field] = value
229
+ } else if (field !== 'permissionDecisionReason' || event !== 'PreToolUse') result[field] = value
205
230
  }
206
231
  return result
207
232
  }
@@ -7,7 +7,7 @@ import { Box, Button, drawWith, Input, Text } from '../ui/elements.js'
7
7
  import { messageOf } from '../utils/text.js'
8
8
  import type { Claude } from './claude.js'
9
9
 
10
- export const installerPaneId = 'cmod-installer'
10
+ export const installerPaneOf = (name: string): string => `installer-${name.replace(/[^A-Za-z0-9_-]/g, '_')}`.slice(0, 64)
11
11
 
12
12
  export type Consent = {
13
13
  readonly install: string
@@ -33,6 +33,7 @@ export type Installer = {
33
33
  }
34
34
 
35
35
  export function createInstaller(name: string, claude: () => Claude): Installer {
36
+ const paneId = installerPaneOf(name)
36
37
  let page: Page | undefined
37
38
  let isOpen = false
38
39
 
@@ -48,7 +49,7 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
48
49
  })
49
50
  if (!isOpen) {
50
51
  isOpen = true
51
- await claude().ui.open({ id: installerPaneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
52
+ await claude().ui.open({ id: paneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
52
53
  }
53
54
  claude().ui.invalidate('ui.render')
54
55
  return answer
@@ -60,12 +61,12 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
60
61
  const bullet = (text: string) => Text({ children: [Text({ color: 'claude', children: '◆ ' }), text] })
61
62
 
62
63
  return {
63
- draws: (e) => e.component === 'Pane' && e.requestId === installerPaneId,
64
+ draws: (e) => e.component === 'Pane' && e.requestId === paneId,
64
65
  draw(e) {
65
66
  return drawWith(claude().ui.resolve(e), () => page?.draw(e.props as Frozen<RenderPropsOf['Pane']>) ?? Text({ dimColor: true, children: `${name} is installing.` }))
66
67
  },
67
68
  closed(id) {
68
- if (id !== installerPaneId) return
69
+ if (id !== paneId) return
69
70
  isOpen = false
70
71
  page?.cancel()
71
72
  },
@@ -102,12 +103,12 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
102
103
  `${name} needs ${missing.length === 1 ? 'one setting' : `${missing.length} settings`}:`,
103
104
  missing.map(([key, option]) => {
104
105
  const label = Text({ children: [Text({ bold: true, children: option.title }), option.description === '' ? '' : Text({ dimColor: true, children: ` ${option.description}` })] })
105
- if (option.kind === 'secret') return Box({ flexDirection: 'column', children: [label, Text({ dimColor: true, children: `Set it in /config, where Claude Code keeps it in your keychain, then press Next.` })] })
106
+ if (option.type === 'secret') return Box({ flexDirection: 'column', children: [label, Text({ dimColor: true, children: `Set it in /config, where Claude Code keeps it in your keychain, then press Next.` })] })
106
107
  const failure = failures.get(key)
107
108
  const field = Input({
108
109
  key: `option:${key}`,
109
110
  label: '› ',
110
- placeholder: option.kind === 'choice' ? (option.choices ?? []).join(', ') : option.kind === 'toggle' ? 'true or false' : option.kind === 'list' ? 'comma-separated' : '',
111
+ placeholder: option.type === 'choice' ? (option.choices ?? []).join(', ') : option.type === 'toggle' ? 'true or false' : option.type === 'list' ? 'comma-separated' : '',
111
112
  value: saved.get(key) ?? '',
112
113
  submitLabel: 'save',
113
114
  onSubmit: (value: string) =>
@@ -131,7 +132,7 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
131
132
  )
132
133
  },
133
134
  step(mod, step, index, total) {
134
- let notice: string | undefined
135
+ let notice: { readonly color: 'warning' | 'error'; readonly text: string } | undefined
135
136
  return show<boolean>(
136
137
  (answer) => ({
137
138
  draw: (props) => {
@@ -142,16 +143,17 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
142
143
  body = Text({ color: 'error', children: `The ${step.title} step could not draw: ${messageOf(error)}` })
143
144
  }
144
145
  const next = async () => {
145
- const isDone = await Promise.resolve(step.isDone(mod)).catch(() => false)
146
- if (isDone) answer(true)
147
- else {
148
- notice = `Finish ${step.title} first.`
149
- claude().ui.invalidate('ui.render')
146
+ try {
147
+ if (await step.isDone(mod)) return answer(true)
148
+ notice = { color: 'warning', text: `Finish ${step.title} first.` }
149
+ } catch (error) {
150
+ notice = { color: 'error', text: `The ${step.title} step's isDone failed: ${messageOf(error)}` }
150
151
  }
152
+ claude().ui.invalidate('ui.render')
151
153
  }
152
154
  return frame(
153
155
  `${step.title} (${index} of ${total})`,
154
- [body, ...(notice === undefined ? [] : [Text({ color: 'warning', children: notice })])],
156
+ [body, ...(notice === undefined ? [] : [Text({ color: notice.color, children: notice.text })])],
155
157
  [Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'next', hotkey: 'x', label: index === total ? 'Finish' : 'Next', onPress: () => void next() })],
156
158
  )
157
159
  },
@@ -163,7 +165,7 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
163
165
  page = undefined
164
166
  if (!isOpen) return
165
167
  isOpen = false
166
- await claude().ui.close({ id: installerPaneId })
168
+ await claude().ui.close({ id: paneId })
167
169
  },
168
170
  }
169
171
  }