@cmodjs/core 2.0.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 (42) hide show
  1. package/docs/commands.md +3 -3
  2. package/docs/dependencies.md +1 -1
  3. package/package.json +1 -1
  4. package/records.d.ts +1 -1
  5. package/records.js +1 -1
  6. package/register.d.ts.map +1 -1
  7. package/register.js +5 -4
  8. package/runtime/before.d.ts +6 -3
  9. package/runtime/before.d.ts.map +1 -1
  10. package/runtime/before.js +59 -37
  11. package/runtime/conversations.d.ts.map +1 -1
  12. package/runtime/conversations.js +38 -13
  13. package/runtime/hooks.d.ts +9 -22
  14. package/runtime/hooks.d.ts.map +1 -1
  15. package/runtime/hooks.js +4 -58
  16. package/runtime/lifecycle.d.ts +2 -1
  17. package/runtime/lifecycle.d.ts.map +1 -1
  18. package/runtime/lifecycle.js +34 -17
  19. package/runtime/permission-check.d.ts +15 -0
  20. package/runtime/permission-check.d.ts.map +1 -0
  21. package/runtime/permission-check.js +37 -0
  22. package/runtime/sessions.d.ts +12 -8
  23. package/runtime/sessions.d.ts.map +1 -1
  24. package/runtime/sessions.js +41 -80
  25. package/runtime/state.d.ts.map +1 -1
  26. package/runtime/state.js +14 -11
  27. package/session/context/turn/message/message.d.ts +1 -0
  28. package/session/context/turn/message/message.d.ts.map +1 -1
  29. package/session/context/turn/message/message.js +5 -2
  30. package/src/records.ts +1 -1
  31. package/src/register.ts +5 -4
  32. package/src/runtime/before.ts +60 -39
  33. package/src/runtime/conversations.ts +35 -13
  34. package/src/runtime/hooks.ts +13 -84
  35. package/src/runtime/lifecycle.ts +49 -31
  36. package/src/runtime/permission-check.ts +47 -0
  37. package/src/runtime/sessions.ts +51 -84
  38. package/src/runtime/state.ts +15 -8
  39. package/src/session/context/turn/message/message.ts +6 -2
  40. package/src/testing.ts +4 -3
  41. package/testing.d.ts.map +1 -1
  42. package/testing.js +4 -3
@@ -13,7 +13,10 @@ export const Message = 'message';
13
13
  export const messageTypes = [Prompt, Note, Reply, ToolCall, ToolResult, Skill, InstructionFile, Notice];
14
14
  export const skillPrefix = 'Base directory for this skill:';
15
15
  const rowTypes = ['user', 'assistant', 'attachment', 'system'];
16
- const memoryPath = /^Contents of (.+?) \(/;
16
+ const memoryPath = /^Contents of (.+?)(?: \([^)\n]*\))?:\r?$/m;
17
+ export function instructionPathOf(text) {
18
+ return memoryPath.exec(text)?.[1] ?? '';
19
+ }
17
20
  export function rowOfLine(row) {
18
21
  const { uuid, timestamp, type } = row;
19
22
  if (typeof uuid !== 'string' || typeof timestamp !== 'string' || typeof type !== 'string' || !rowTypes.includes(type))
@@ -39,7 +42,7 @@ export function rowOfLine(row) {
39
42
  export function rowOfAppend(append, at, cwd) {
40
43
  const { message, origin } = append;
41
44
  const text = textOf(message.content);
42
- const path = message.type === 'attachment' ? memoryPath.exec(text)?.[1] : undefined;
45
+ const path = message.type === 'attachment' ? instructionPathOf(text) || undefined : undefined;
43
46
  const model = isObject(origin) && origin['kind'] === 'model' && typeof origin['model'] === 'string' ? origin['model'] : undefined;
44
47
  return {
45
48
  id: append.uuid,
package/src/records.ts CHANGED
@@ -259,7 +259,7 @@ function readKeys(keys: unknown): Steps {
259
259
  return { keys: commands }
260
260
  }
261
261
 
262
- export const coreVersion = '2.0.0'
262
+ export const coreVersion = '2.0.1'
263
263
 
264
264
  export function oldestCmodFor(steps: Steps): string {
265
265
  if (steps.permissions?.some((item) => item.startsWith('config:')) === true) return '1.6.0'
package/src/register.ts CHANGED
@@ -5,10 +5,11 @@ import type { Claude } from './runtime/claude.js'
5
5
  import type { RoutedEvent } from './runtime/hooks.js'
6
6
  import { cmodPluginName } from './runtime/dependencies.js'
7
7
  import { createLifecycle, readPlugin, type Lifecycle } from './runtime/lifecycle.js'
8
+ import { permissionRoutes, type PermissionRoutes } from './runtime/permission-check.js'
8
9
 
9
10
  let lifecycle: Lifecycle<object>
10
11
 
11
- let checksPermissions = false
12
+ let permissionCheck: PermissionRoutes | undefined
12
13
 
13
14
  let registered: ModDefinition<object> | undefined
14
15
 
@@ -99,9 +100,9 @@ function routeCallToMod(_$: EngineInterface, eventInput: Frozen<Args<'cmod.call'
99
100
  }
100
101
 
101
102
  export function registerMod<State extends object, Declared extends Options>(addHook: On, definition: ModDefinition<State, string, Declared>, options: PluginOptions): void {
102
- checksPermissions = false
103
+ permissionCheck = undefined
103
104
  registered = definition as ModDefinition<object>
104
- lifecycle = createLifecycle(definition, () => checksPermissions, options)
105
+ lifecycle = createLifecycle(definition, () => permissionCheck, options)
105
106
  addHook('session.start', startMod)
106
107
  addHook('classic.Setup', routeToMod)
107
108
  addHook('classic.SessionStart', routeToMod)
@@ -154,7 +155,7 @@ export function registerMod<State extends object, Declared extends Options>(addH
154
155
  }
155
156
 
156
157
  export function registerPermissionCheck(addHook: On): void {
157
- checksPermissions = true
158
+ permissionCheck = permissionRoutes
158
159
  addHook('tool.check', routeToMod)
159
160
  addHook('classic.PermissionRequest', routeToMod)
160
161
  }
@@ -2,7 +2,7 @@ import type { Args, Frozen } from 'claude-code'
2
2
  import type { Disposable } from '../mod.js'
3
3
  import { ContextEnd } from '../session/context/context.js'
4
4
  import { InstructionFile, isRulePath } from '../session/context/turn/message/instruction-file.js'
5
- import { Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
5
+ import { instructionPathOf, Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
6
6
  import { Note } from '../session/context/turn/message/note.js'
7
7
  import { Notice } from '../session/context/turn/message/notice.js'
8
8
  import { Prompt } from '../session/context/turn/message/prompt.js'
@@ -14,11 +14,12 @@ import { ToolResult } from '../session/context/turn/message/tool-result.js'
14
14
  import { TurnEnd } from '../session/context/turn/turn.js'
15
15
  import type { ConversationState, SessionChange, TaskData } from '../session/conversation.js'
16
16
  import { Task, TaskStart } from '../session/goal/task.js'
17
- import type { BeforeEvent } from '../session/session.js'
17
+ import { sessionEvents, type BeforeEvent, type SessionEvent } from '../session/session.js'
18
18
  import type { Claude } from './claude.js'
19
19
  import { cmodPluginName } from './dependencies.js'
20
20
  import type { Router } from './router.js'
21
21
  import { toolInputOf } from './tool-calls.js'
22
+ import { resolve } from '../vendor.js'
22
23
 
23
24
  export type Place = Pick<ConversationState, 'session' | 'context' | 'contextOpenedAt' | 'turn' | 'tasks' | 'taskTurns' | 'openCalls'>
24
25
 
@@ -66,6 +67,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
66
67
  }
67
68
  const titles = new Map<string, string>()
68
69
  const spawned = new Map<string, string>()
70
+ const agentFolders = new Map<string, string>()
69
71
  const toolResultHook = (isFailure: boolean) => async (e: Readonly<Record<string, unknown>>, next: (e: never) => Promise<unknown>) => {
70
72
  if (!(await wants(ToolResult))) return next(e as never)
71
73
  const where = await place()
@@ -121,7 +123,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
121
123
  router.add('prompt.attachment', async (e, next) => {
122
124
  if (!(await wants(InstructionFile)) || e.type !== 'nested_memory') return next(e)
123
125
  const where = await place()
124
- const path = /^Contents of (.+?) \(/.exec(e.text)?.[1] ?? ''
126
+ const path = instructionPathOf(e.text)
125
127
  const answer = await asked(InstructionFile, message(where, { id: '', at: now(), type: InstructionFile, path, text: e.text, isRule: isRulePath(path) }, e.agentId))
126
128
  if (answer.refuse !== undefined) return { text: null }
127
129
  return answer.text === undefined ? next(e) : { text: answer.text }
@@ -145,7 +147,8 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
145
147
  if (!(await wants(ToolCall))) return next(e)
146
148
  const where = await place()
147
149
  const input = toolInputOf(e)
148
- const answer = await asked(ToolCall, message(where, { id: e.tool_use_id, at: now(), type: ToolCall, callId: e.tool_use_id, name: e.tool, input, cwd: await claude.session.cwd(), files: noFiles }, e.agentId))
150
+ const cwd = (e.agentId === undefined ? undefined : agentFolders.get(e.agentId)) ?? (await claude.session.cwd())
151
+ const answer = await asked(ToolCall, message(where, { id: e.tool_use_id, at: now(), type: ToolCall, callId: e.tool_use_id, name: e.tool, input, cwd, files: noFiles }, e.agentId))
149
152
  if (answer.refuse !== undefined) return { deny: answer.refuse }
150
153
  if (answer.result !== undefined) return { result: answer.result }
151
154
  if (answer.input === undefined) return next(e)
@@ -154,7 +157,13 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
154
157
  }),
155
158
  router.add('agent.spawn', async (e, next) => {
156
159
  spawned.set(e.tool_use_id, e.prompt)
157
- if (!(await wants(TaskStart))) return next(e)
160
+ const start = async (args: typeof e) => {
161
+ const started = await next(args)
162
+ const folder = args.isTeammate === true ? undefined : (args.cwd ?? (args.parentAgentId === undefined ? undefined : agentFolders.get(args.parentAgentId)))
163
+ if (started.agentId !== undefined && folder !== undefined) agentFolders.set(started.agentId, resolve(await claude.session.cwd(), folder))
164
+ return started
165
+ }
166
+ if (!(await wants(TaskStart))) return start(e)
158
167
  const where = await place()
159
168
  const task: TaskData = { id: '', title: e.description, agentType: e.subagentType, model: e.model ?? e.parentModel, openedBy: e.tool_use_id, openedAt: now(), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }) }
160
169
  const answer = await asked(TaskStart, { type: TaskStart, task: { ...task, context: where.context } })
@@ -162,7 +171,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
162
171
  spawned.delete(e.tool_use_id)
163
172
  return { deny: answer.refuse }
164
173
  }
165
- return next(answer.owner === undefined ? e : { ...e, model: answer.owner.model })
174
+ return start(answer.owner === undefined ? e : { ...e, model: answer.owner.model })
166
175
  }),
167
176
  router.add('classic.SubagentStart', async (e, next) => {
168
177
  const where = await place()
@@ -177,12 +186,16 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
177
186
  return withNote(await next(e), answer.note)
178
187
  }),
179
188
  router.add('classic.SubagentStop', async (e, next) => {
180
- if (!(await wants(TurnEnd))) return next(e)
189
+ const stopped = <Answer extends { readonly block?: string }>(answer: Answer) => {
190
+ if (answer.block === undefined) agentFolders.delete(e.agent_id)
191
+ return answer
192
+ }
193
+ if (!(await wants(TurnEnd))) return stopped(await next(e))
181
194
  const turn = (await place()).taskTurns[e.agent_id]
182
- if (turn === undefined) return next(e)
195
+ if (turn === undefined) return stopped(await next(e))
183
196
  const answer = await asked(TurnEnd, { type: TurnEnd, turn: { ...turn, closedAt: now() } })
184
197
  const below = await next(e)
185
- return answer.refuse === undefined ? below : { ...below, block: answer.refuse }
198
+ return stopped(answer.refuse === undefined ? below : { ...below, block: answer.refuse })
186
199
  }),
187
200
  router.add('classic.Stop', async (e, next) => {
188
201
  if (!(await wants(TurnEnd))) return next(e)
@@ -213,41 +226,49 @@ export function withAnswer(change: SessionChange, answer: Partial<Answers>): Ses
213
226
  return change
214
227
  }
215
228
 
216
- export async function foldAnswers(event: BeforeEvent, change: SessionChange, askers: readonly ((change: SessionChange) => Promise<unknown>)[], report: (error: unknown) => void): Promise<Partial<Answers> | undefined> {
229
+ export const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
230
+
231
+ export const beforeAnswerKeys: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
232
+ prompt: ['text', 'note', 'title', 'refuse'],
233
+ note: ['text'],
234
+ skill: ['text'],
235
+ instructionFile: ['text', 'refuse'],
236
+ reply: ['text'],
237
+ toolCall: ['input', 'result', 'refuse'],
238
+ toolResult: ['output', 'isError', 'note'],
239
+ notice: ['text'],
240
+ taskStart: ['owner', 'refuse'],
241
+ turnEnd: ['refuse'],
242
+ contextEnd: ['refuse'],
243
+ }
244
+
245
+ export function foldBefore(event: BeforeEvent, change: SessionChange, askers: readonly ((change: SessionChange) => Promise<unknown>)[], report: (error: unknown) => void): Promise<Partial<Answers> | undefined> {
246
+ return foldAnswers(methodOf('before', event), beforeAnswerKeys[event], ['refuse', 'result'], change, askers, report, withAnswer)
247
+ }
248
+
249
+ export function takenAnswer(method: string, takes: readonly string[], answer: unknown): unknown {
250
+ if (answer === undefined || answer === null || answer === false) return undefined
251
+ if (typeof answer !== 'object' || Array.isArray(answer)) throw new Error(`it answered ${JSON.stringify(answer)}, but ${method} takes an object or nothing: ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
252
+ const unread = Object.keys(answer).filter((key) => !takes.includes(key))
253
+ if (unread.length > 0) throw new Error(`it answered ${unread.join(' and ')}, which ${method} does not take. It takes ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
254
+ return answer
255
+ }
256
+
257
+ export async function foldAnswers<Change, Answer extends object>(method: string, takes: readonly string[], ends: readonly string[], change: Change, askers: readonly ((change: Change) => Promise<unknown>)[], report: (error: unknown) => void, applied: (change: Change, answer: Answer) => Change = (same) => same): Promise<Answer | undefined> {
217
258
  let latest = change
218
- let result: Partial<Answers> | undefined
259
+ let result: Record<string, unknown> | undefined
219
260
  for (const ask of askers) {
220
261
  try {
221
- const answer = answerOf(event, await ask(latest))
222
- if (answer === undefined) continue
223
- const notes = [result?.note, answer.note].filter((note) => note !== undefined)
224
- result = { ...result, ...answer, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
225
- if (answer.refuse !== undefined || answer.result !== undefined) return result
226
- latest = withAnswer(latest, answer)
262
+ const answer = takenAnswer(method, takes, await ask(latest))
263
+ if (typeof answer !== 'object' || answer === null) continue
264
+ const given = answer as Record<string, unknown>
265
+ const notes = [result?.['note'], given['note']].filter((note) => typeof note === 'string')
266
+ result = { ...result, ...given, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
267
+ if (ends.some((key) => given[key] !== undefined)) return result as Answer
268
+ latest = applied(latest, answer as Answer)
227
269
  } catch (error) {
228
270
  report(error)
229
271
  }
230
272
  }
231
- return result
232
- }
233
-
234
- export function answerOf(event: BeforeEvent, answer: unknown): Partial<Answers> | undefined {
235
- if (typeof answer !== 'object' || answer === null) return undefined
236
- const given = answer as Partial<Answers>
237
- const allowed: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
238
- prompt: ['text', 'note', 'title', 'refuse'],
239
- note: ['text'],
240
- skill: ['text'],
241
- instructionFile: ['text', 'refuse'],
242
- reply: ['text'],
243
- toolCall: ['input', 'result', 'refuse'],
244
- toolResult: ['output', 'isError', 'note'],
245
- notice: ['text'],
246
- taskStart: ['owner', 'refuse'],
247
- turnEnd: ['refuse'],
248
- contextEnd: ['refuse'],
249
- }
250
- const unread = Object.keys(given).filter((key) => !allowed[event].includes(key as keyof Answers))
251
- if (unread.length > 0) throw new Error(`a before(${event}) listener answered ${unread.join(' and ')}, which ${event} does not take. It takes ${allowed[event].map((key) => `{ ${key} }`).join(', ')}.`)
252
- return given
273
+ return result as Answer | undefined
253
274
  }
@@ -9,7 +9,7 @@ import { GoalChange } from '../session/goal/goal.js'
9
9
  import { receivedMatcher } from '../utils/matcher.js'
10
10
  import { ifMissing } from '../utils/paths.js'
11
11
  import { formatExit, messageOf } from '../utils/text.js'
12
- import { foldAnswers, installBefore, relayOf, type Place } from './before.js'
12
+ import { foldBefore, installBefore, relayOf, type Place } from './before.js'
13
13
  import type { Claude } from './claude.js'
14
14
  import { cmodPluginName } from './dependencies.js'
15
15
  import { readLines, runCmod } from './programs.js'
@@ -80,7 +80,7 @@ export function serveConversations({ claude, router, store, project, report }: {
80
80
  router.add('classic.StopFailure', async (e, next) => endTurn({ reason: 'error', failure: { type: String(e.error), text: e.error_details ?? e.last_assistant_message ?? String(e.error) } })(await next(e)))
81
81
  router.add('classic.SubagentStop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) }, e.agent_id)(await next(e)))
82
82
 
83
- 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
83
+ const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], await claude.session.id())) as Value
84
84
 
85
85
  const isGranted = async (caller: string | undefined, item: string) => {
86
86
  if (caller === cmodPluginName) return true
@@ -90,6 +90,7 @@ export function serveConversations({ claude, router, store, project, report }: {
90
90
  return (parseConsent(JSON.parse(text), path)[caller] ?? []).includes(item)
91
91
  }
92
92
 
93
+
93
94
  const ended = (id: string) => new Error(`session ${id} has ended`)
94
95
 
95
96
  const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
@@ -144,17 +145,36 @@ export function serveConversations({ claude, router, store, project, report }: {
144
145
  if (sent.isDelivered !== true) throw ended(to)
145
146
  }
146
147
 
148
+ const waitingOn = new Map<Asked, string>()
149
+ let liveness: { cancel(): void } | undefined
150
+ let isChecking = false
151
+ const checkLiveness = async () => {
152
+ if (isChecking) return
153
+ isChecking = true
154
+ try {
155
+ const waiting = [...waitingOn]
156
+ const running = new Set((await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).map((each) => each.id))
157
+ for (const [settle, to] of waiting) if (!running.has(to)) settle.reject(ended(to))
158
+ } finally {
159
+ isChecking = false
160
+ }
161
+ }
162
+ const watchLife = (settle: Asked, to: string) => {
163
+ waitingOn.set(settle, to)
164
+ liveness ??= claude.clock.every(livenessMs, () => void checkLiveness().catch((error: unknown) => report('liveness', 'Could not check which sessions still run', error)))
165
+ return {
166
+ cancel() {
167
+ waitingOn.delete(settle)
168
+ if (waitingOn.size > 0) return
169
+ liveness?.cancel()
170
+ liveness = undefined
171
+ },
172
+ }
173
+ }
174
+
147
175
  const ask = (to: string, relay: Question, deadlineMs?: number) =>
148
176
  new Promise<unknown>((resolve, reject) => {
149
177
  const id = `${session}:${(asks += 1)}`
150
- const timer =
151
- deadlineMs === undefined
152
- ? claude.clock.every(livenessMs, () => {
153
- void isRunning(to).then((isLive) => {
154
- if (!isLive) settle.reject(ended(to))
155
- })
156
- })
157
- : claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
158
178
  const settle: Asked = {
159
179
  resolve(value) {
160
180
  if (asked.delete(id)) timer.cancel()
@@ -165,6 +185,7 @@ export function serveConversations({ claude, router, store, project, report }: {
165
185
  reject(error)
166
186
  },
167
187
  }
188
+ const timer = deadlineMs === undefined ? watchLife(settle, to) : claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
168
189
  asked.set(id, settle)
169
190
  post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
170
191
  })
@@ -244,7 +265,7 @@ export function serveConversations({ claude, router, store, project, report }: {
244
265
  return deciders(event).length > 0
245
266
  },
246
267
  decide: (event, change) =>
247
- foldAnswers(
268
+ foldBefore(
248
269
  event,
249
270
  change,
250
271
  deciders(event).map((watcher) => (latest: SessionChange) => (watcher.from === session ? askHere(watcher.mod, event, latest) : askAway(watcher, event, latest))),
@@ -556,7 +577,7 @@ export function serveConversations({ claude, router, store, project, report }: {
556
577
  const action = String(request['action']) as Act
557
578
  const task = typeof request['task'] === 'string' ? request['task'] : undefined
558
579
  if (!isAct(action)) throw new Error(`cmod takes no action named ${action}.`)
559
- if (target === session) return perform(caller, action, request['input'], task)
580
+ if (target === (await claude.session.id())) return perform(caller, action, request['input'], task)
560
581
  return actElsewhere(caller, target, action, request['input'], task)
561
582
  },
562
583
  }
@@ -572,7 +593,8 @@ export function serveConversations({ claude, router, store, project, report }: {
572
593
  const method = methods[e.method]
573
594
  if (method === undefined) return undefined
574
595
  const input = isObject(e.input) ? e.input : {}
575
- if (e.method !== conversationMethods.act && input['session'] !== session && !(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() }
596
+ const isDropping = (e.method === conversationMethods.subscribe || e.method === conversationMethods.watchBefore) && Array.isArray(input['subscriptions']) && input['subscriptions'].length === 0
597
+ if (e.method !== conversationMethods.act && !isDropping && input['session'] !== (await claude.session.id()) && !(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() }
576
598
  try {
577
599
  return { value: await method(input, e.caller) }
578
600
  } catch (error) {
@@ -1,4 +1,4 @@
1
- import type { ClassicHookEvent, ClassicHookInputs, PermissionRequestDecision } from 'claude-code'
1
+ import type { ClassicHookEvent, ClassicHookInputs } from 'claude-code'
2
2
  import { configFolders } from '../records.js'
3
3
  import type { Claude } from './claude.js'
4
4
  import type { RoutedHook } from './router.js'
@@ -10,26 +10,9 @@ import { messageOf } from '../utils/text.js'
10
10
 
11
11
  export type Hook = Exclude<ClassicHookEvent, 'PreToolUse'>
12
12
 
13
- export type HookAnswer = {
14
- decision?: 'block'
15
- reason?: string
16
- hookSpecificOutput?: {
17
- hookEventName?: Hook
18
- additionalContext?: string
19
- permissionDecision?: 'deny' | 'ask'
20
- permissionDecisionReason?: string
21
- sessionTitle?: string
22
- initialUserMessage?: string
23
- watchPaths?: string[]
24
- reloadSkills?: boolean
25
- decision?: PermissionRequestDecision
26
- retry?: boolean
27
- displayContent?: string
28
- worktreePath?: string
29
- }
30
- }
13
+ export type HookFields = Readonly<Record<string, unknown>>
31
14
 
32
- export type HookListener<H extends Hook> = (input: ClassicHookInputs[H]) => HookAnswer | void | Promise<HookAnswer | void>
15
+ export type HookListener<H extends Hook> = (input: ClassicHookInputs[H]) => HookFields | undefined | Promise<HookFields | undefined>
33
16
 
34
17
  export type RoutedEvent =
35
18
  | `classic.${Hook}`
@@ -54,61 +37,18 @@ export type RoutedEvent =
54
37
 
55
38
  export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
56
39
 
57
- type ClassicFields = Record<string, unknown>
58
-
59
40
  type CallFiles = { readonly read: string[]; readonly changed: string[] }
60
41
 
61
- const flags: readonly string[] = ['reloadSkills', 'retry']
62
-
63
- const readFields: Record<Hook, readonly string[]> = {
64
- Setup: ['additionalContext'],
65
- SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
66
- SessionEnd: [],
67
- UserPromptSubmit: ['additionalContext', 'sessionTitle'],
68
- UserPromptExpansion: ['additionalContext'],
69
- InstructionsLoaded: [],
70
- ConfigChange: [],
71
- DirectoryAdded: [],
72
- PreModelSwitch: ['permissionDecision', 'permissionDecisionReason'],
73
- PostModelSwitch: ['additionalContext'],
74
- PermissionRequest: ['decision'],
75
- PermissionDenied: ['retry'],
76
- PostToolUse: ['additionalContext'],
77
- PostToolUseFailure: ['additionalContext'],
78
- PostToolBatch: ['additionalContext'],
79
- MessageDisplay: ['displayContent'],
80
- SubagentStart: ['additionalContext'],
81
- SubagentStop: ['additionalContext'],
82
- TaskCreated: [],
83
- TaskCompleted: [],
84
- TeammateIdle: [],
85
- Elicitation: [],
86
- ElicitationResult: [],
87
- Notification: [],
88
- PreCompact: [],
89
- PostCompact: [],
90
- Stop: ['additionalContext'],
91
- StopFailure: [],
92
- CwdChanged: [],
93
- FileChanged: [],
94
- WorktreeCreate: ['worktreePath'],
95
- WorktreeRemove: [],
96
- }
97
-
98
- export function classicHook<H extends Hook>(name: string, event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
42
+ export function classicHook<H extends Hook>(event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
99
43
  const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
100
44
  const ours = await Promise.resolve()
101
- .then(async () => {
102
- const answer = await hook(e as ClassicHookInputs[H])
103
- return answer === undefined ? undefined : classicResult(event, answer)
104
- })
105
- .catch((error: unknown): ClassicFields | undefined => {
106
- if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: `${name}: the ${event} listener failed: ${messageOf(error)}` } }
45
+ .then(() => hook(e as ClassicHookInputs[H]))
46
+ .catch((error: unknown) => {
107
47
  report({ id: `hook:${event}`, message: `The ${event} listener failed`, error })
108
48
  return undefined
109
49
  })
110
50
  if (ours === undefined) return next(e)
111
- return mergeClassic((await next(e)) as ClassicFields, ours)
51
+ return mergeClassic((await next(e)) as HookFields, ours)
112
52
  }
113
53
  return routed as unknown as RoutedHook<RoutedEvent>
114
54
  }
@@ -134,9 +74,11 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
134
74
  }
135
75
  }
136
76
 
137
- export async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
77
+ export type HomeFolders = { readonly home: string | undefined; readonly configRoot: string | undefined }
78
+
79
+ export async function callFiles(name: string, claude: Claude, folders: Promise<HomeFolders>, use: CallBase, cwd: string): Promise<CallFiles> {
138
80
  try {
139
- const [home, configRoot] = await Promise.all([claude.env.home(), claude.env.configHome()])
81
+ const { home, configRoot } = await folders
140
82
  if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
141
83
  const { shell, reads, writes } = callEffects(use, { cwd, home, ...(configRoot === undefined ? {} : { configRoot }), fs: claude.fs })
142
84
  const known = (accesses: FileAccess[]) => accesses.map(({ path }) => path).filter((path) => shell === undefined || !dynamicPattern.test(path))
@@ -152,21 +94,8 @@ export async function callFiles(name: string, claude: Claude, use: CallBase, cwd
152
94
  }
153
95
  }
154
96
 
155
- function classicResult(event: Hook, answer: HookAnswer): ClassicFields {
156
- const result: ClassicFields = {}
157
- if (answer.decision === 'block') result['block'] = answer.reason ?? ''
158
- const specific = answer.hookSpecificOutput ?? {}
159
- for (const [field, value] of Object.entries(specific)) {
160
- if (field === 'hookEventName' || (value === false && flags.includes(field))) continue
161
- if (!readFields[event].includes(field)) throw new Error(`it answered ${field}, which ${event} does not read.`)
162
- result[field] = field === 'additionalContext' ? [value] : value
163
- }
164
- return result
165
- }
166
-
167
-
168
- function mergeClassic(below: ClassicFields, ours: ClassicFields): ClassicFields {
169
- const merged: ClassicFields = { ...below, ...ours }
97
+ function mergeClassic(below: HookFields, ours: HookFields): HookFields {
98
+ const merged: Record<string, unknown> = { ...below, ...ours }
170
99
  const context = [...((below['additionalContext'] as string[] | undefined) ?? []), ...((ours['additionalContext'] as string[] | undefined) ?? [])]
171
100
  if (context.length > 0) merged['additionalContext'] = context
172
101
  return merged