@cmodjs/core 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/docs/commands.md +2 -2
  2. package/docs/jobs.md +1 -1
  3. package/docs/mod.md +4 -3
  4. package/docs/permissions.md +1 -1
  5. package/docs/session.md +12 -2
  6. package/docs/testing.md +6 -3
  7. package/jobs/context.d.ts.map +1 -1
  8. package/jobs/context.js +2 -1
  9. package/jobs/permissions.d.ts.map +1 -1
  10. package/jobs/permissions.js +5 -2
  11. package/jobs/prompt.d.ts.map +1 -1
  12. package/jobs/prompt.js +2 -1
  13. package/jobs/slash-command.d.ts.map +1 -1
  14. package/jobs/slash-command.js +4 -3
  15. package/package.json +1 -1
  16. package/records.d.ts +1 -1
  17. package/records.js +1 -1
  18. package/register.d.ts.map +1 -1
  19. package/register.js +58 -51
  20. package/runtime/before.d.ts +3 -2
  21. package/runtime/before.d.ts.map +1 -1
  22. package/runtime/before.js +10 -7
  23. package/runtime/conversations.d.ts.map +1 -1
  24. package/runtime/conversations.js +46 -19
  25. package/runtime/engine.d.ts +1 -0
  26. package/runtime/engine.d.ts.map +1 -1
  27. package/runtime/hooks.d.ts +2 -2
  28. package/runtime/hooks.d.ts.map +1 -1
  29. package/runtime/hooks.js +7 -1
  30. package/runtime/lifecycle.d.ts +2 -1
  31. package/runtime/lifecycle.d.ts.map +1 -1
  32. package/runtime/lifecycle.js +33 -15
  33. package/runtime/permission-check.d.ts +3 -0
  34. package/runtime/permission-check.d.ts.map +1 -1
  35. package/runtime/permission-check.js +19 -6
  36. package/runtime/router.d.ts +3 -2
  37. package/runtime/router.d.ts.map +1 -1
  38. package/runtime/router.js +19 -12
  39. package/runtime/sessions.d.ts +14 -4
  40. package/runtime/sessions.d.ts.map +1 -1
  41. package/runtime/sessions.js +72 -28
  42. package/runtime/toaster.d.ts.map +1 -1
  43. package/runtime/toaster.js +3 -0
  44. package/session/context/turn/message/message.d.ts.map +1 -1
  45. package/session/context/turn/message/message.js +30 -2
  46. package/session/context/turn/message/note.d.ts +7 -0
  47. package/session/context/turn/message/note.d.ts.map +1 -1
  48. package/session/context/turn/message/note.js +12 -0
  49. package/session/session.d.ts +3 -1
  50. package/session/session.d.ts.map +1 -1
  51. package/src/jobs/context.ts +2 -1
  52. package/src/jobs/permissions.ts +11 -5
  53. package/src/jobs/prompt.ts +2 -1
  54. package/src/jobs/slash-command.ts +4 -3
  55. package/src/records.ts +1 -1
  56. package/src/register.ts +63 -52
  57. package/src/runtime/before.ts +13 -9
  58. package/src/runtime/conversations.ts +54 -27
  59. package/src/runtime/engine.ts +1 -0
  60. package/src/runtime/hooks.ts +9 -3
  61. package/src/runtime/lifecycle.ts +39 -19
  62. package/src/runtime/permission-check.ts +44 -22
  63. package/src/runtime/router.ts +23 -15
  64. package/src/runtime/sessions.ts +96 -38
  65. package/src/runtime/toaster.ts +2 -0
  66. package/src/session/context/turn/message/message.ts +27 -2
  67. package/src/session/context/turn/message/note.ts +16 -0
  68. package/src/session/session.ts +1 -2
  69. package/src/testing/fake-claude.ts +4 -3
  70. package/src/testing.ts +19 -12
  71. package/testing/fake-claude.d.ts +1 -0
  72. package/testing/fake-claude.d.ts.map +1 -1
  73. package/testing/fake-claude.js +3 -2
  74. package/testing.d.ts +7 -2
  75. package/testing.d.ts.map +1 -1
  76. package/testing.js +15 -9
@@ -4,6 +4,7 @@ import { defined, processPayloadOf, recordOf, type HookOn, type RouteOn, type To
4
4
 
5
5
  export type PermissionUse = {
6
6
  readonly raise: (method: string, payload: HookFields) => Promise<HookFields | undefined>
7
+ readonly refusalFor: (method: string, payload: HookFields, error: Error) => { readonly refuse: string } | undefined
7
8
  readonly hookOn: HookOn
8
9
  readonly routeOn: RouteOn
9
10
  readonly current: () => { readonly id: string; readonly cwd: string }
@@ -15,33 +16,54 @@ type Verdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?:
15
16
 
16
17
  const verdictName = (decision: Verdict['decision']) => (decision === 'deny' ? 'refuse' : decision)
17
18
 
18
- export const permissionRoutes: PermissionRoutes = (method, { raise, hookOn, routeOn, current }) => {
19
+ export const permissionRoutes: PermissionRoutes = (method, { raise, refusalFor, hookOn, routeOn, current }) => {
19
20
  const isBefore = method.startsWith('before')
20
21
  if (method.endsWith('PermissionCheck')) {
21
- return routeOn('tool.check', async (e: ToolCheck, next) => {
22
- const below = (await next(e)) as Verdict
22
+ const payloadOf = (e: ToolCheck, below: Verdict) => {
23
23
  const { id, cwd } = current()
24
- const payload = { session: id, cwd, ...defined({ task: e.agentId, callId: e.tool_use_id, reason: below.reason, rule: below.rule, ceiling: e.ceiling === undefined ? undefined : verdictName(e.ceiling) }), name: e.tool, input: recordOf(e.input), decision: verdictName(below.decision) }
24
+ return { session: id, cwd, ...defined({ task: e.agentId, callId: e.tool_use_id, reason: below.reason, rule: below.rule, ceiling: e.ceiling === undefined ? undefined : verdictName(e.ceiling) }), name: e.tool, input: recordOf(e.input), decision: verdictName(below.decision) }
25
+ }
26
+ const failed = async (e: ToolCheck, error: Error, next: (e: ToolCheck) => Promise<unknown>) => {
27
+ const refusal = refusalFor(method, payloadOf(e, (await next(e)) as Verdict), error)
28
+ return refusal === undefined ? undefined : { decision: 'deny' as const, reason: refusal.refuse }
29
+ }
30
+ return routeOn(
31
+ 'tool.check',
32
+ async (e: ToolCheck, next) => {
33
+ const below = (await next(e)) as Verdict
34
+ const payload = payloadOf(e, below)
35
+ if (!isBefore) {
36
+ void raise(method, payload)
37
+ return below
38
+ }
39
+ const answer = await raise(method, payload)
40
+ if (answer === undefined || below.decision === 'deny') return below
41
+ if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
42
+ if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
43
+ return answer['allow'] === true ? { decision: 'allow' } : below
44
+ },
45
+ isBefore ? failed : undefined,
46
+ )
47
+ }
48
+ const refused = (refuse: string, interrupt?: unknown) => ({ decision: defined({ behavior: 'deny', message: refuse, interrupt }) })
49
+ return hookOn(
50
+ 'PermissionRequest',
51
+ async (input) => {
52
+ const payload = processPayloadOf('PermissionRequest', input)
25
53
  if (!isBefore) {
26
54
  void raise(method, payload)
27
- return below
55
+ return undefined
28
56
  }
29
57
  const answer = await raise(method, payload)
30
- if (answer === undefined || below.decision === 'deny') return below
31
- if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
32
- if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
33
- return answer['allow'] === true ? { decision: 'allow' } : below
34
- })
35
- }
36
- return hookOn('PermissionRequest', async (input) => {
37
- const payload = processPayloadOf('PermissionRequest', input)
38
- if (!isBefore) {
39
- void raise(method, payload)
40
- return undefined
41
- }
42
- const answer = await raise(method, payload)
43
- if (answer === undefined) return undefined
44
- if (typeof answer['refuse'] === 'string') return { decision: defined({ behavior: 'deny', message: answer['refuse'], interrupt: answer['interrupt'] }) }
45
- return answer['allow'] === true ? { decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) } : undefined
46
- })
58
+ if (answer === undefined) return undefined
59
+ if (typeof answer['refuse'] === 'string') return refused(answer['refuse'], answer['interrupt'])
60
+ return answer['allow'] === true ? { decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) } : undefined
61
+ },
62
+ isBefore
63
+ ? (input, error) => {
64
+ const refusal = refusalFor(method, processPayloadOf('PermissionRequest', input), error)
65
+ return refusal === undefined ? undefined : refused(refusal.refuse)
66
+ }
67
+ : undefined,
68
+ )
47
69
  }
@@ -6,38 +6,46 @@ export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<
6
6
 
7
7
  export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: RouterNext<N>) => EventResult<N> | Promise<EventResult<N>>
8
8
 
9
+ export type RoutedFailure<N extends RoutedEvent> = (e: Frozen<Args<N>>, error: Error, next: RouterNext<N>) => EventResult<N> | undefined | Promise<EventResult<N> | undefined>
10
+
9
11
  export type Router = {
10
- add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): Disposable
11
- has(event: RoutedEvent): boolean
12
+ add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>, failed?: RoutedFailure<N>): Disposable
12
13
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
14
+ dispatchFailure<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, error: Error, next: RouterNext<N>): Promise<EventResult<N>>
13
15
  }
14
16
 
15
- type ErasedHook = (e: unknown, next: (e: unknown) => Promise<unknown>) => unknown
17
+ type Route = { readonly hook: (e: unknown, next: (e: unknown) => Promise<unknown>) => unknown; readonly failed?: (e: unknown, error: Error, next: (e: unknown) => Promise<unknown>) => unknown }
16
18
 
17
19
  export function createRouter(): Router {
18
- const hooks = new Map<RoutedEvent, ErasedHook[]>()
20
+ const routes = new Map<RoutedEvent, Route[]>()
19
21
 
20
22
  return {
21
- add(event, hook) {
22
- const erased = hook as unknown as ErasedHook
23
- hooks.set(event, [...(hooks.get(event) ?? []), erased])
23
+ add(event, hook, failed) {
24
+ const route = { hook, ...(failed === undefined ? {} : { failed }) } as Route
25
+ routes.set(event, [...(routes.get(event) ?? []), route])
24
26
  return {
25
27
  dispose() {
26
- const chain = (hooks.get(event) ?? []).filter((each) => each !== erased)
27
- if (chain.length === 0) hooks.delete(event)
28
- else hooks.set(event, chain)
28
+ const chain = (routes.get(event) ?? []).filter((each) => each !== route)
29
+ if (chain.length === 0) routes.delete(event)
30
+ else routes.set(event, chain)
29
31
  },
30
32
  }
31
33
  },
32
- has: (event) => hooks.has(event),
33
34
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
34
- const chain = hooks.get(event)
35
+ const chain = routes.get(event)
35
36
  const call = async (index: number, current: unknown): Promise<unknown> => {
36
- const hook = chain?.[index]
37
- if (hook === undefined) return next(current as Frozen<Args<N>>)
38
- return hook(current, (passed) => call(index + 1, passed))
37
+ const route = chain?.[index]
38
+ if (route === undefined) return next(current as Frozen<Args<N>>)
39
+ return route.hook(current, (passed) => call(index + 1, passed))
39
40
  }
40
41
  return call(0, e) as Promise<EventResult<N>>
41
42
  },
43
+ async dispatchFailure<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, error: Error, next: RouterNext<N>): Promise<EventResult<N>> {
44
+ for (const route of routes.get(event) ?? []) {
45
+ const answer = await route.failed?.(e, error, next as (e: unknown) => Promise<unknown>)
46
+ if (answer !== undefined) return answer as EventResult<N>
47
+ }
48
+ return next(e)
49
+ },
42
50
  }
43
51
  }
@@ -1,8 +1,8 @@
1
- import type { Args, CommandRunResult } from 'claude-code'
1
+ import type { Args, CommandRunResult, SessionCompactResult } from 'claude-code'
2
2
  import type { Disposable } from '../mod.js'
3
3
  import { ContextEnd, ContextStart, type Context } from '../session/context/context.js'
4
4
  import { Message, noFiles } from '../session/context/turn/message/message.js'
5
- import { Note } from '../session/context/turn/message/note.js'
5
+ import { modNote, Note } from '../session/context/turn/message/note.js'
6
6
  import { Person, senderOf } from '../session/context/turn/message/sender.js'
7
7
  import { ToolCall } from '../session/context/turn/message/tool-call.js'
8
8
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
@@ -20,7 +20,7 @@ import { listenerOf, PassedTimeout, withinTime, type ListenOptions, type ListenT
20
20
  import { callFiles, type HomeFolders, type HookFields } from './hooks.js'
21
21
  import type { PermissionRoutes } from './permission-check.js'
22
22
 
23
- export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
23
+ export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined; readonly onFailure?: 'refuse'; readonly scope?: Scope }
24
24
 
25
25
  export const everySession = '*'
26
26
 
@@ -67,6 +67,7 @@ export type SessionsClient = {
67
67
  readonly sessions: Sessions
68
68
  changed(session: string, change: SessionChange, isInProject: boolean): void
69
69
  answerBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<unknown>
70
+ failedBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean, error: Error): { readonly refuse: string } | undefined
70
71
  raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
71
72
  listen(method: string, args: readonly unknown[]): Disposable
72
73
  /** An empty title keeps the title the session already has. */
@@ -133,7 +134,11 @@ export function isInScope(scope: Scope, session: string, change: SessionChange,
133
134
  return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
134
135
  }
135
136
 
136
- export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>) => Disposable
137
+ export function withoutPermissionCheck(name: string, hearer: string): string {
138
+ return `${name}: ${hearer} hears Claude Code's permission check, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.`
139
+ }
140
+
141
+ export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>, failed?: (input: unknown, error: Error) => HookFields | undefined) => Disposable
137
142
 
138
143
  export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn, permissionRoutes }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean; readonly hookOn?: HookOn; readonly routeOn?: RouteOn; readonly permissionRoutes?: () => PermissionRoutes | undefined }): SessionsClient {
139
144
  const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
@@ -151,7 +156,8 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
151
156
  queued.add(key)
152
157
  void Promise.resolve().then(async () => {
153
158
  queued.delete(key)
154
- const heard = (from: Map<string, Set<Listening>>): Subscription[] => [...(from.get(key) ?? [])].map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher) }))
159
+ const heard = (from: Map<string, Set<Listening>>): Subscription[] =>
160
+ [...(from.get(key) ?? [])].map(({ event, matcher, options, scope }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher), ...(options.onFailure === 'refuse' ? { onFailure: 'refuse' as const, scope } : {}) }))
155
161
  const tracksTasks = [...(listening.get(key) ?? []), ...(deciding.get(key) ?? [])].some(({ scope }) => scope.task !== undefined || scope.isGoal === true)
156
162
  const subscriptions = [...heard(listening), ...(tracksTasks ? [{ event: TaskStart }] : [])]
157
163
  await call(conversationMethods.subscribe, { session: key, subscriptions }).catch((error: unknown) => report(`sessions:${key}`, `Hears nothing from ${key === everySession ? 'the other sessions' : `session ${key}`}`, error))
@@ -196,27 +202,32 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
196
202
  }
197
203
  }
198
204
 
199
-
205
+ const refusalOf = (options: ListenOptions, takes: readonly string[], error: unknown) => (options.onFailure === 'refuse' && takes.includes('refuse') ? { refuse: `${name}: ${messageOf(error)}` } : undefined)
200
206
 
201
207
  const invoke = (method: string, options: ListenOptions, run: (time: ListenTime) => unknown, takes: readonly string[] = []): Promise<unknown> =>
202
208
  withinTime(claude.clock, method, options, run).catch((error: unknown) => {
203
209
  if (error instanceof PassedTimeout && !method.startsWith('before')) return undefined
204
210
  report(`session:${method}`, `The ${method} listener failed`, error)
205
- return options.onFailure === 'refuse' && takes.includes('refuse') ? { refuse: `${name}: ${messageOf(error)}` } : undefined
211
+ return refusalOf(options, takes, error)
206
212
  })
207
213
 
214
+ const decidersOf = (session: string, event: BeforeEvent, isInProject: boolean) => [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])].filter((entry) => entry.event === event)
215
+
216
+ const decides = (entry: Listening, session: string, change: SessionChange) => isInScope(entry.scope, session, change, isUnderIn(session)) && (entry.matcher === undefined || matches(payloadOf(change), entry.matcher))
217
+
218
+ const mayDecide = (session: string) => session === current.id || isGranted('conversation')
219
+
208
220
  const combined = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<Partial<Answers> | undefined> => {
209
- const askers = [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])]
210
- .filter((entry) => entry.event === event)
221
+ const askers = decidersOf(session, event, isInProject)
211
222
  .map((entry) => async (latest: SessionChange) => {
212
- if (!isInScope(entry.scope, session, latest, isUnderIn(session))) return undefined
213
- if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
223
+ if (!decides(entry, session, latest)) return undefined
214
224
  const payload = await payloadFor(session, latest)
215
225
  const answer = await invoke(entry.method, entry.options, async (time) => takenAnswer(entry.method, beforeAnswerKeys[event], await entry.listener(payload as never, time)), beforeAnswerKeys[event])
216
226
  if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
217
227
  throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
218
228
  })
219
- return foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
229
+ const answer = await foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
230
+ return answer?.note === undefined ? answer : { ...answer, note: modNote(name, answer.note) }
220
231
  }
221
232
 
222
233
  const eventMethods = (scopeOf: () => Scope, hasBefore: boolean, ended?: string): OnMethods & Partial<BeforeMethods> => {
@@ -325,7 +336,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
325
336
  const sendHere = async ({ type, text, as }: SessionSend) => {
326
337
  if (type === Note) {
327
338
  if (as !== undefined) throw new Error(`${name}: a Note is always from this mod, so it takes no as.`)
328
- const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
339
+ const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text: modNote(name, text) }] } })
329
340
  if (added.deny !== undefined) throw new Error(`${name}: Claude Code refused the note: ${added.deny}`)
330
341
  return
331
342
  }
@@ -428,21 +439,26 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
428
439
  )
429
440
  }
430
441
 
431
- type ProcessListener = { readonly matcher: unknown; readonly listener: (payload: HookFields, time: ListenTime) => unknown; readonly options: ListenOptions }
442
+ type ProcessListener = { readonly matcher: unknown; readonly listener: (payload: HookFields, time: ListenTime) => unknown; readonly options: ListenOptions; readonly isEveryAgent: boolean }
432
443
  const processListeners = new Map<string, Set<ProcessListener>>()
433
444
 
445
+ const processHearers = (method: string, payload: HookFields) => [...(processListeners.get(method) ?? [])].filter(({ matcher, isEveryAgent }) => (payload['task'] === undefined || isEveryAgent) && (matcher === undefined || matches(payload, matcher)))
446
+
434
447
  const raise = async (method: string, payload: HookFields): Promise<HookFields | undefined> => {
435
- const heard = [...(processListeners.get(method) ?? [])].filter(({ matcher }) => payload['task'] === undefined && (matcher === undefined || matches(payload, matcher)))
448
+ const heard = processHearers(method, payload)
436
449
  if (!method.startsWith('before')) {
437
450
  await Promise.all(heard.map(({ listener, options }) => invoke(method, options, (time) => listener(payload, time))))
438
451
  return undefined
439
452
  }
440
- const failing = method === 'beforePermissionRequest' ? { onFailure: 'refuse' as const } : {}
441
- const takes = answerKeys[method.slice('before'.length)] ?? []
442
- const askers = heard.map(({ listener, options }) => () => invoke(method, { ...options, ...failing }, async (time) => takenAnswer(method, takes, await listener(payload, time)), takes))
453
+ const takes = takesOf(method)
454
+ const askers = heard.map(({ listener, options }) => () => invoke(method, options, async (time) => takenAnswer(method, takes, await listener(payload, time)), takes))
443
455
  return foldAnswers<undefined, HookFields>(method, takes, ['refuse'], undefined, askers, (error) => report(`session:${method}`, `The ${method} listener failed`, error))
444
456
  }
445
457
 
458
+ const takesOf = (method: string) => answerKeys[method.slice('before'.length)] ?? []
459
+
460
+ const refusalFor = (method: string, payload: HookFields, error: Error) => processHearers(method, payload).map((entry) => refusalOf(entry.options, takesOf(method), error)).find((answer) => answer !== undefined)
461
+
446
462
  const commandRun = async (e: Args<'command.run'>): Promise<HookFields> => {
447
463
  const source = (await claude.command.list()).find((command) => command.name === e.command)?.source
448
464
  return { session: current.id, cwd: current.cwd, name: e.command, args: e.args, ...defined({ source }), from: senderOf(e.origin, false) }
@@ -461,7 +477,10 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
461
477
  const args = typeof changes?.['args'] === 'string' ? changes['args'] : e.args
462
478
  const ran = await next(args === e.args ? e : { ...e, args })
463
479
  void raise('onRun', { ...run, args })
464
- return typeof changes?.['note'] === 'string' ? { ...ran, context: [...(ran.context ?? []), changes['note']] } : ran
480
+ return typeof changes?.['note'] === 'string' ? { ...ran, context: [...(ran.context ?? []), modNote(name, changes['note'])] } : ran
481
+ }, async (e, error) => {
482
+ const refusal = commandListeners.beforeRun.size === 0 ? undefined : refusalFor('beforeRun', await commandRun(e), error)
483
+ return refusal === undefined ? undefined : { text: refusal.refuse }
465
484
  })
466
485
 
467
486
  const listenTo = (method: string, named: string) => {
@@ -469,24 +488,55 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
469
488
  const isBefore = method.startsWith('before')
470
489
  if (named === 'PermissionCheck' || named === 'PermissionRequest') {
471
490
  const routes = permissionRoutes?.()
472
- if (routes !== undefined) return routes(method, { raise, hookOn, routeOn, current: () => current })
473
- return named === 'PermissionCheck' ? routeOn('tool.check', (e, next) => next(e)) : hookOn('PermissionRequest', () => undefined)
491
+ if (routes === undefined) throw new Error(withoutPermissionCheck(name, method))
492
+ return routes(method, { raise, refusalFor, hookOn, routeOn, current: () => current })
474
493
  }
475
494
  const event: { readonly on: string; readonly before?: string } = processEvents[named as ProcessName]
476
495
  const hook = (isBefore ? event.before : undefined) ?? event.on
477
- return hookOn(hook, async (input: unknown) => {
478
- const payload = processPayloadOf(named as ProcessFieldsName, input)
479
- if (!isBefore) {
480
- if (named !== 'SessionStart' || payload['source'] !== 'compact') void raise(method, payload)
481
- return undefined
482
- }
483
- const answer = await raise(method, payload)
484
- return answer === undefined ? undefined : hookAnswerOf(named, answer)
485
- })
496
+ if (method === 'beforeSessionStart') routeOn('session.compact', compactionNote)
497
+ const failed = (input: unknown, error: Error) => {
498
+ const refusal = refusalFor(method, processPayloadOf(named as ProcessFieldsName, input), error)
499
+ return refusal === undefined ? undefined : hookAnswerOf(name, named, refusal)
500
+ }
501
+ return hookOn(
502
+ hook,
503
+ async (input: unknown) => {
504
+ const payload = processPayloadOf(named as ProcessFieldsName, input)
505
+ if (named === 'SessionStart' && payload['source'] === 'compact') {
506
+ const started = isBefore ? compactionStarts.shift() : undefined
507
+ return started === undefined ? undefined : hookAnswerOf(name, named, started)
508
+ }
509
+ if (!isBefore) {
510
+ void raise(method, payload)
511
+ return undefined
512
+ }
513
+ const answer = await raise(method, payload)
514
+ return answer === undefined ? undefined : hookAnswerOf(name, named, answer)
515
+ },
516
+ isBefore ? failed : undefined,
517
+ )
486
518
  }
487
519
 
488
- const listen = (method: string, args: readonly unknown[]): Disposable => {
489
- const entry = listenerOf(name, method, args) as ProcessListener
520
+ const compactionStarts: HookFields[] = []
521
+
522
+ const compactionNote = async (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>): Promise<SessionCompactResult> => {
523
+ const compacted = await next(e)
524
+ if (e.trigger === 'precompute' || compacted.skip !== undefined) return compacted
525
+ const model = e.agentId === undefined ? await claude.session.model() : undefined
526
+ const answer = await raise('beforeSessionStart', { session: current.id, cwd: current.cwd, ...defined({ task: e.agentId, model, title: current.title || undefined, contextTokenCount: compacted.tokensAfter }), source: 'compact' })
527
+ const { note, ...started } = answer ?? {}
528
+ if (Object.keys(started).length > 0) compactionStarts.push(started)
529
+ if (typeof note !== 'string') return compacted
530
+ if (!isGranted('prompt')) {
531
+ report('session:beforeSessionStart', 'The beforeSessionStart listener failed', new Error(`${name}: its note adds text Claude reads, which needs the prompt permission.`))
532
+ return compacted
533
+ }
534
+ return { ...compacted, messages: [...compacted.messages, { role: 'user', text: modNote(name, note), toolUses: [] }] }
535
+ }
536
+
537
+ const listen = (method: string, args: readonly unknown[], isEveryAgent = false): Disposable => {
538
+ const heard = listenerOf(name, method, args)
539
+ const entry = { ...heard, options: method === 'beforePermissionRequest' ? { ...heard.options, onFailure: 'refuse' } : heard.options, isEveryAgent } as ProcessListener
490
540
  const held = processListeners.get(method) ?? new Set()
491
541
  if (!processListeners.has(method)) {
492
542
  listenTo(method, method.replace(/^(on|before)/, ''))
@@ -501,10 +551,16 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
501
551
  Object.entries(processEvents).flatMap(([named, event]) => [`on${named}`, ...('before' in event ? [`before${named}`] : [])]).map((method) => [method, (...args: unknown[]) => listen(method, args)]),
502
552
  ) as ProcessOnMethods & ProcessBeforeMethods
503
553
 
504
- const currentSession = Object.assign(sessionOf(() => current), processMethods())
554
+ const here = sessionOf(() => current)
555
+ const currentSession = Object.assign(here, processMethods(), { goal: Object.assign(here.goal, { beforeSessionStart: (...args: unknown[]) => listen('beforeSessionStart', args, true) }) })
505
556
  const every = eventMethods(() => ({}), false)
506
557
 
507
- const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (session === current.id || isGranted('conversation') ? combined(session, event, change, isInProject) : undefined)
558
+ const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (mayDecide(session) ? combined(session, event, change, isInProject) : undefined)
559
+
560
+ const failedBefore = (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean, error: Error) => {
561
+ const asked = mayDecide(session) ? decidersOf(session, event, isInProject).filter((entry) => decides(entry, session, change)) : []
562
+ return asked.map((entry) => refusalOf(entry.options, beforeAnswerKeys[event], error)).find((answer) => answer !== undefined)
563
+ }
508
564
 
509
565
  return {
510
566
  sessions: {
@@ -537,6 +593,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
537
593
  }
538
594
  },
539
595
  answerBefore,
596
+ failedBefore,
540
597
  raise,
541
598
  listen,
542
599
  follow(next) {
@@ -561,8 +618,9 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
561
618
  export type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
562
619
 
563
620
  export type RouteOn = {
564
- (event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>): Disposable
565
- (event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>): Disposable
621
+ (event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>, failed?: (e: ToolCheck, error: Error, next: (e: ToolCheck) => Promise<unknown>) => Promise<{ readonly decision: 'deny'; readonly reason: string } | undefined>): Disposable
622
+ (event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>, failed?: (e: Args<'command.run'>, error: Error) => Promise<CommandRunResult | undefined>): Disposable
623
+ (event: 'session.compact', hook: (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>) => Promise<SessionCompactResult>): Disposable
566
624
  }
567
625
 
568
626
  export const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
@@ -659,9 +717,9 @@ export function processPayloadOf(named: ProcessFieldsName, input: unknown): Hook
659
717
  return { ...placementOf(fields), ...defined(processFields[named](fields)) }
660
718
  }
661
719
 
662
- function hookAnswerOf(named: string, answer: HookFields): HookFields | undefined {
720
+ function hookAnswerOf(name: string, named: string, answer: HookFields): HookFields | undefined {
663
721
  const refuse = typeof answer['refuse'] === 'string' ? answer['refuse'] : undefined
664
- const note = typeof answer['note'] === 'string' ? [answer['note']] : undefined
722
+ const note = typeof answer['note'] === 'string' ? [modNote(name, answer['note'])] : undefined
665
723
  if (named === 'SessionStart') return defined({ additionalContext: note, sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'] === true ? true : undefined, initialUserMessage: answer['firstPrompt'] })
666
724
  if (named === 'ModelChange') {
667
725
  if (refuse !== undefined) return { permissionDecision: 'deny', permissionDecisionReason: refuse }
@@ -180,6 +180,8 @@ export function createToaster(claude: Claude, name: string, version: () => strin
180
180
  const hides = `${version() ?? ''}\n${message}\n${error === undefined ? '' : describe(error)}`
181
181
  if (hidden.get(id) === hides) return id
182
182
  hidden.delete(id)
183
+ const shown = entries.get(id)
184
+ if (shown?.hides === hides && shown.trying === undefined && shown.options.description === description) return id
183
185
  const stack = error instanceof Error && error.stack !== undefined ? `\n\n${error.stack}` : ''
184
186
  const details = async () => {
185
187
  const modVersion = await claude.fs
@@ -1,11 +1,11 @@
1
1
  import { isObject } from '../../../../records.js'
2
2
  import { Task } from '../../../goal/task.js'
3
3
  import { InstructionFile, isRulePath } from './instruction-file.js'
4
- import { Note } from './note.js'
4
+ import { modNote, modNoteBlock, modNotesIn, Note } from './note.js'
5
5
  import { Notice } from './notice.js'
6
6
  import { Prompt } from './prompt.js'
7
7
  import { Reply } from './reply.js'
8
- import { senderOf } from './sender.js'
8
+ import { Mod, senderOf } from './sender.js'
9
9
  import { Skill } from './skill.js'
10
10
  import { ToolCall, type CallFiles } from './tool-call.js'
11
11
  import { ToolResult } from './tool-result.js'
@@ -108,8 +108,11 @@ export function messagesOf(row: Row, isInTask: boolean): Unplaced[] {
108
108
  function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
109
109
  const base = { id: row.id, at: row.at }
110
110
  const text = textOf(row.content).trim()
111
+ const notesFrom = (each: readonly { readonly mod: string; readonly text: string }[]): Unplaced[] => each.map((note) => ({ ...base, type: Note, text: note.text, from: { type: Mod, name: note.mod } }))
111
112
  if (row.type === 'system') return row.name === 'turn_duration' || row.name === 'compact_boundary' || text === '' ? [] : [{ ...base, type: Notice, text }]
113
+ if (row.type === 'attachment' && row.name === 'hook_additional_context') return notesFrom(row.content.flatMap((entry) => modNotesIn(typeof entry === 'string' ? entry : textOf([entry]))))
112
114
  if (row.type === 'attachment') return row.name === 'nested_memory' ? [{ ...base, type: InstructionFile, path: row.path ?? '', text, isRule: isRulePath(row.path ?? '') }] : []
115
+ if (isModNotesRow(row)) return notesFrom(modNotesIn(text))
113
116
  if (row.type === 'assistant') {
114
117
  return row.content.filter(isObject).flatMap((block): Unplaced[] => {
115
118
  if (block['type'] === 'text' && typeof block['text'] === 'string' && block['text'].trim() !== '') return [{ ...base, type: Reply, text: block['text'].trim() }]
@@ -128,7 +131,29 @@ function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
128
131
  return prompt === undefined ? [] : [{ ...base, type: Prompt, text: prompt, from: senderOf(row.origin, isInTask) }]
129
132
  }
130
133
 
134
+ function isModNotesRow(row: Row): boolean {
135
+ const text = textOf(row.content)
136
+ return row.type === 'user' && !row.content.some(isToolResultBlock) && modNotesIn(text).length > 0 && text.replace(modNoteBlock, '').trim() === ''
137
+ }
138
+
139
+ function withNoteTexts(entries: readonly unknown[], texts: ReadonlyMap<number, string>): unknown[] {
140
+ let index = -1
141
+ const replaced = (text: string) =>
142
+ text.replace(modNoteBlock, (whole, mod: string) => {
143
+ index += 1
144
+ const changed = texts.get(index)
145
+ if (changed === undefined) return whole
146
+ return changed === '' ? '' : modNote(mod, changed)
147
+ })
148
+ return entries.map((entry) => {
149
+ if (typeof entry === 'string') return replaced(entry)
150
+ return isObject(entry) && entry['type'] === 'text' && typeof entry['text'] === 'string' ? { ...entry, text: replaced(entry['text']) } : entry
151
+ })
152
+ }
153
+
131
154
  export function withTexts(row: Row, texts: ReadonlyMap<number, string>): readonly unknown[] {
155
+ if (row.type === 'attachment' && row.name === 'hook_additional_context') return withNoteTexts(row.content, texts)
156
+ if (isModNotesRow(row)) return keepingMedia(row.content, withNoteTexts([textOf(row.content)], texts)[0] as string)
132
157
  if (row.type === 'assistant') {
133
158
  let index = -1
134
159
  return row.content.map((block) => {
@@ -1,3 +1,4 @@
1
+ import { pluginName } from '../../../../records.js'
1
2
  import type { MessageOf } from './message.js'
2
3
  import type { Sender } from './sender.js'
3
4
 
@@ -7,3 +8,18 @@ export type Note = MessageOf<typeof Note> & {
7
8
  readonly text: string
8
9
  readonly from: Sender
9
10
  }
11
+
12
+ export const modNoteBlock = new RegExp(`<(${pluginName.source.slice(1, -1)})_mod>\\n([\\s\\S]*?)\\n</\\1_mod>`, 'g')
13
+
14
+ export function modNote(mod: string, text: string): string {
15
+ return `<${mod}_mod>\n${text}\n</${mod}_mod>`
16
+ }
17
+
18
+ export function modNotesIn(text: string): { readonly mod: string; readonly text: string }[] {
19
+ return [...text.matchAll(modNoteBlock)].map((match) => ({ mod: match[1] as string, text: match[2] as string }))
20
+ }
21
+
22
+ export function untagged(text: string): string {
23
+ const notes = modNotesIn(text)
24
+ return notes.length === 0 ? text : notes.map((note) => note.text).join('\n\n')
25
+ }
@@ -120,7 +120,6 @@ export type OnMethods = { readonly [Name in EventName as `on${Name}`]: OnMethod<
120
120
 
121
121
  export type BeforeMethods = { readonly [Name in BeforeName as `before${Name}`]: BeforeMethod<PayloadOf<Name>, AnswerOf<Name>> }
122
122
 
123
-
124
123
  export type RecordPart = OnMethods &
125
124
  BeforeMethods & {
126
125
  search(text: string, matcher?: Matcher<Message>): AsyncIterable<Message>
@@ -327,7 +326,7 @@ export type ProcessOnMethods = { readonly [Name in ProcessName as `on${Name}`]:
327
326
 
328
327
  export type ProcessBeforeMethods = { readonly [Name in keyof ProcessAnswer as `before${Name}`]: BeforeMethod<ProcessAsked[Name], ProcessAnswer[Name]> }
329
328
 
330
- export type CurrentSession = Session & ProcessOnMethods & ProcessBeforeMethods
329
+ export type CurrentSession = Omit<Session, 'goal'> & ProcessOnMethods & ProcessBeforeMethods & { readonly goal: Goal & Pick<ProcessBeforeMethods, 'beforeSessionStart'> }
331
330
 
332
331
  export type SessionScope = {
333
332
  readonly running?: boolean
@@ -1,6 +1,7 @@
1
1
  import type { EventResult, Timer } from 'claude-code'
2
2
  import { isObject } from '../records.js'
3
3
  import type { Claude } from '../runtime/claude.js'
4
+ import { untagged } from '../session/context/turn/message/note.js'
4
5
  import { elements } from './fake-elements.js'
5
6
  import { fakeFiles } from './fake-files.js'
6
7
 
@@ -21,7 +22,7 @@ export type Fakes = {
21
22
  prompt: { submit?: Claude['prompt']['submit'] }
22
23
  model: { complete?: Claude['model']['complete'] }
23
24
  agent: { list?: Claude['agent']['list']; spawn?: Claude['agent']['spawn'] }
24
- clock: { after?: Claude['clock']['after']; every?: Claude['clock']['every'] }
25
+ clock: { now?: Claude['clock']['now']; after?: Claude['clock']['after']; every?: Claude['clock']['every'] }
25
26
  cmod: { call?: Claude['cmod']['call'] }
26
27
  tool: { call?: Claude['tool']['call'] }
27
28
  }
@@ -79,7 +80,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
79
80
  }
80
81
 
81
82
  const appendNote: Claude['session']['append'] = async ({ message }) => {
82
- shown.notes.push(message.content.map((block) => (typeof block['text'] === 'string' ? block['text'] : '')).join(''))
83
+ shown.notes.push(untagged(message.content.map((block) => (typeof block['text'] === 'string' ? block['text'] : '')).join('')))
83
84
  return { message, uuid: `note-${shown.notes.length}` }
84
85
  }
85
86
  const submitPrompt: Claude['prompt']['submit'] = async ({ text }) => {
@@ -175,7 +176,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
175
176
  keys: async () => [...store.keys()],
176
177
  },
177
178
  clock: {
178
- now: async () => Date.now(),
179
+ now: () => (fakes.clock.now === undefined ? Promise.resolve(Date.now()) : fakes.clock.now()),
179
180
  after: faked('clock.after', () => fakes.clock.after ?? realTimer),
180
181
  every: faked('clock.every', () => fakes.clock.every ?? idleInterval),
181
182
  },