@cmodjs/core 2.1.0 → 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 (70) hide show
  1. package/docs/commands.md +1 -1
  2. package/docs/mod.md +2 -0
  3. package/docs/session.md +9 -1
  4. package/docs/testing.md +4 -2
  5. package/jobs/context.d.ts.map +1 -1
  6. package/jobs/context.js +2 -1
  7. package/jobs/permissions.d.ts.map +1 -1
  8. package/jobs/permissions.js +1 -1
  9. package/jobs/prompt.d.ts.map +1 -1
  10. package/jobs/prompt.js +2 -1
  11. package/jobs/slash-command.d.ts.map +1 -1
  12. package/jobs/slash-command.js +4 -3
  13. package/package.json +1 -1
  14. package/records.d.ts +1 -1
  15. package/records.js +1 -1
  16. package/register.d.ts.map +1 -1
  17. package/register.js +58 -51
  18. package/runtime/before.d.ts +1 -1
  19. package/runtime/before.d.ts.map +1 -1
  20. package/runtime/before.js +6 -5
  21. package/runtime/conversations.d.ts.map +1 -1
  22. package/runtime/conversations.js +46 -19
  23. package/runtime/hooks.d.ts +2 -1
  24. package/runtime/hooks.d.ts.map +1 -1
  25. package/runtime/hooks.js +7 -0
  26. package/runtime/lifecycle.d.ts +2 -1
  27. package/runtime/lifecycle.d.ts.map +1 -1
  28. package/runtime/lifecycle.js +31 -9
  29. package/runtime/permission-check.d.ts +3 -0
  30. package/runtime/permission-check.d.ts.map +1 -1
  31. package/runtime/permission-check.js +19 -6
  32. package/runtime/router.d.ts +3 -1
  33. package/runtime/router.d.ts.map +1 -1
  34. package/runtime/router.js +19 -11
  35. package/runtime/sessions.d.ts +13 -4
  36. package/runtime/sessions.d.ts.map +1 -1
  37. package/runtime/sessions.js +67 -26
  38. package/runtime/toaster.d.ts.map +1 -1
  39. package/runtime/toaster.js +3 -0
  40. package/session/context/turn/message/message.d.ts.map +1 -1
  41. package/session/context/turn/message/message.js +30 -2
  42. package/session/context/turn/message/note.d.ts +7 -0
  43. package/session/context/turn/message/note.d.ts.map +1 -1
  44. package/session/context/turn/message/note.js +12 -0
  45. package/session/session.d.ts +3 -1
  46. package/session/session.d.ts.map +1 -1
  47. package/src/jobs/context.ts +2 -1
  48. package/src/jobs/permissions.ts +8 -4
  49. package/src/jobs/prompt.ts +2 -1
  50. package/src/jobs/slash-command.ts +4 -3
  51. package/src/records.ts +1 -1
  52. package/src/register.ts +63 -52
  53. package/src/runtime/before.ts +7 -6
  54. package/src/runtime/conversations.ts +54 -27
  55. package/src/runtime/hooks.ts +9 -1
  56. package/src/runtime/lifecycle.ts +37 -13
  57. package/src/runtime/permission-check.ts +44 -22
  58. package/src/runtime/router.ts +23 -13
  59. package/src/runtime/sessions.ts +91 -37
  60. package/src/runtime/toaster.ts +2 -0
  61. package/src/session/context/turn/message/message.ts +27 -2
  62. package/src/session/context/turn/message/note.ts +16 -0
  63. package/src/session/session.ts +1 -2
  64. package/src/testing/fake-claude.ts +4 -3
  65. package/src/testing.ts +6 -5
  66. package/testing/fake-claude.d.ts +1 -0
  67. package/testing/fake-claude.d.ts.map +1 -1
  68. package/testing/fake-claude.js +3 -2
  69. package/testing.d.ts.map +1 -1
  70. package/testing.js +5 -4
@@ -1,7 +1,7 @@
1
1
  import type { Args, EventResult, Frozen } from 'claude-code'
2
2
  import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
3
3
  import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
4
- import { Note } from '../session/context/turn/message/note.js'
4
+ import { modNote, Note } from '../session/context/turn/message/note.js'
5
5
  import { Person } from '../session/context/turn/message/sender.js'
6
6
  import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
7
7
  import { Session, UsageChange, type BeforeEvent, type Surface, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
@@ -9,18 +9,19 @@ 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 { foldBefore, installBefore, relayOf, type Place } from './before.js'
12
+ import { beforeAnswerKeys, 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'
16
- import type { Router } from './router.js'
17
- import { actPermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
16
+ import type { RoutedHook, Router } from './router.js'
17
+ import { PassedTimeout } from './deadline.js'
18
+ import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
18
19
 
19
20
  type Request = Readonly<Record<string, unknown>>
20
21
 
21
22
  type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
22
23
 
23
- type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[] }
24
+ type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[]; readonly refusing?: readonly Subscription[] }
24
25
 
25
26
  type Watcher = Watch & { readonly from: string }
26
27
 
@@ -37,6 +38,8 @@ const bufferedTaskRows = 200
37
38
 
38
39
  const beforeDeadlineMs = 5000
39
40
 
41
+ const awayFoldMs = 8000
42
+
40
43
  const livenessMs = 5000
41
44
 
42
45
 
@@ -185,7 +188,7 @@ export function serveConversations({ claude, router, store, project, report }: {
185
188
  reject(error)
186
189
  },
187
190
  }
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`)))
191
+ const timer = deadlineMs === undefined ? watchLife(settle, to) : claude.clock.after(deadlineMs, () => settle.reject(new PassedTimeout(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
189
192
  asked.set(id, settle)
190
193
  post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
191
194
  })
@@ -240,11 +243,24 @@ export function serveConversations({ claude, router, store, project, report }: {
240
243
  return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
241
244
  }
242
245
 
243
- const askAway = async (watcher: Watcher, event: BeforeEvent, change: SessionChange) => {
246
+ const isUnderHere = (task: string, ancestor: string) => {
247
+ for (let at: string | undefined = task, steps = 0; at !== undefined && steps < 64; at = conversation.state.tasks[at]?.parent, steps += 1) if (at === ancestor) return true
248
+ return false
249
+ }
250
+
251
+ const refuses = (watcher: Watcher, event: BeforeEvent, change: SessionChange) =>
252
+ beforeAnswerKeys[event].includes('refuse') && (watcher.refusing ?? []).some(({ event: heard, matcher, scope }) => isInScope(scope ?? {}, session, change, isUnderHere) && isHeardBy(heard, matcher === undefined ? undefined : receivedMatcher(matcher), change))
253
+
254
+ const askAway = async (watcher: Watcher, event: BeforeEvent, change: SessionChange, endsAt: number) => {
244
255
  try {
245
- return await ask(watcher.from, { kind: 'before', mod: watcher.mod, event, change, project: project() }, beforeDeadlineMs)
256
+ const leftMs = Math.min(beforeDeadlineMs, endsAt - (await claude.clock.now()))
257
+ if (leftMs <= 0) throw new PassedTimeout(`the ${awayFoldMs / 1000} s cmod waits on other sessions ran out before session ${watcher.from} was asked`)
258
+ return await ask(watcher.from, { kind: 'before', mod: watcher.mod, event, change, project: project() }, leftMs)
246
259
  } catch (error) {
247
- if (await isRunning(watcher.from)) throw error
260
+ if (await isRunning(watcher.from)) {
261
+ if (error instanceof PassedTimeout && refuses(watcher, event, change)) return { refuse: `${watcher.mod}: ${error.message}` }
262
+ throw error
263
+ }
248
264
  watchers = watchers.filter((each) => each.from !== watcher.from)
249
265
  claude.ui.log(`cmod: session ${watcher.from} ended without clearing its before listeners, so they are cleared now`, { to: 'debug' })
250
266
  await claude.fs.write(`${watches}/${watcher.from}.json`, '[]')
@@ -264,13 +280,20 @@ export function serveConversations({ claude, router, store, project, report }: {
264
280
  await Promise.all([starting, watching])
265
281
  return deciders(event).length > 0
266
282
  },
267
- decide: (event, change) =>
268
- foldBefore(
269
- event,
270
- change,
271
- deciders(event).map((watcher) => (latest: SessionChange) => (watcher.from === session ? askHere(watcher.mod, event, latest) : askAway(watcher, event, latest))),
272
- (error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
273
- ),
283
+ fold: () => {
284
+ let endsAt: Promise<number> | undefined
285
+ return (event, change) =>
286
+ foldBefore(
287
+ event,
288
+ change,
289
+ deciders(event).map((watcher) => async (latest: SessionChange) => {
290
+ if (watcher.from === session) return askHere(watcher.mod, event, latest)
291
+ endsAt ??= claude.clock.now().then((now) => now + awayFoldMs)
292
+ return askAway(watcher, event, latest, await endsAt)
293
+ }),
294
+ (error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
295
+ )
296
+ },
274
297
  },
275
298
  })
276
299
 
@@ -321,22 +344,24 @@ export function serveConversations({ claude, router, store, project, report }: {
321
344
  return next(e)
322
345
  })
323
346
 
324
- router.add('session.append', async (e, next) => {
347
+ const recordAppend: RoutedHook<'session.append'> = async (e, next) => {
325
348
  const kept = await next(e)
326
349
  if (kept.deny !== undefined || session === '') return kept
327
350
  const cwd = e.message.type === 'assistant' ? await claude.session.cwd() : ''
328
351
  void take(rowOfAppend({ uuid: kept.uuid, door: e.door, origin: e.origin, message: kept.message }, new Date().toISOString(), cwd), e.agentId).catch((error: unknown) => report('append', 'Missed a message', error))
329
352
  return kept
330
- })
353
+ }
354
+ router.add('session.append', recordAppend, (e, _error, next) => recordAppend(e, next))
331
355
 
332
- router.add('session.compact', async (e, next) => {
356
+ const recordCompaction: RoutedHook<'session.compact'> = async (e, next) => {
333
357
  const compacted = await next(e)
334
358
  if (e.trigger === 'precompute' || compacted.skip !== undefined || session === '') return compacted
335
359
  const at = new Date().toISOString()
336
360
  const boundary: Row = { id: `compact:${e.agentId ?? session}:${at}`, at, type: 'system', name: 'compact_boundary', isMeta: false, isSummary: false, content: [] }
337
361
  void take(boundary, e.agentId).catch((error: unknown) => report('compact', 'Missed a compaction', error))
338
362
  return compacted
339
- })
363
+ }
364
+ router.add('session.compact', recordCompaction, (e, _error, next) => recordCompaction(e, next))
340
365
 
341
366
  router.add('session.measure', async (e, next) => {
342
367
  const measured = await next(e)
@@ -344,7 +369,7 @@ export function serveConversations({ claude, router, store, project, report }: {
344
369
  return measured
345
370
  })
346
371
 
347
- router.add('agent.spawn', async (e, next) => {
372
+ const recordSpawn: RoutedHook<'agent.spawn'> = async (e, next) => {
348
373
  const spawned = await next(e)
349
374
  if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
350
375
  const id = spawned.agentId
@@ -355,7 +380,8 @@ export function serveConversations({ claude, router, store, project, report }: {
355
380
  waitingRows.delete(id)
356
381
  for (const row of waiting) dispatch(session, conversation.add(row, id))
357
382
  return spawned
358
- })
383
+ }
384
+ router.add('agent.spawn', recordSpawn, (e, _error, next) => recordSpawn(e, next))
359
385
 
360
386
  router.add('session.receive', async (e, next) => {
361
387
  const sent = relayOf(e)
@@ -398,13 +424,13 @@ export function serveConversations({ claude, router, store, project, report }: {
398
424
  await checkAct(caller, action, input)
399
425
  await starting
400
426
  if (action === 'abort') return abortTurn(input, task)
401
- if (task !== undefined) return performInTask(action, input, task)
427
+ if (task !== undefined) return performInTask(caller, action, input, task)
402
428
  if (action === 'usage') return usageOf(claude)
403
429
  if (action === 'surfaces') return [...(await claude.session.surfaces())] satisfies Surface[]
404
430
  if (action === 'send') {
405
431
  const { type, text, as } = input as SessionSend
406
432
  if (as === Session) throw new Error(`session ${session} cannot send as itself to itself.`)
407
- const delivered = type === Note ? await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }) : await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
433
+ const delivered = type === Note ? await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text: modNote(caller, text) }] } }) : await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
408
434
  if ('deny' in delivered && typeof delivered.deny === 'string') throw new Error(`Claude Code refused the note: ${delivered.deny}`)
409
435
  if ('drop' in delivered && typeof delivered.drop === 'string') throw new Error(`Claude Code dropped the prompt: ${delivered.drop}`)
410
436
  return null
@@ -424,13 +450,14 @@ export function serveConversations({ claude, router, store, project, report }: {
424
450
  return null
425
451
  }
426
452
 
427
- const performInTask = async (action: Act, input: unknown, task: string): Promise<unknown> => {
453
+ const performInTask = async (caller: string, action: Act, input: unknown, task: string): Promise<unknown> => {
428
454
  const running = conversation.state.tasks[task]
429
455
  if (action === 'usage') return json(['history', 'usage', session, '--task', task])
430
456
  if (action === 'surfaces') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
431
457
  if (action !== 'send') throw new Error(`cmod: ${action} runs only on a session's own main loop.`)
432
458
  if (running === undefined || running.closedAt !== undefined) throw ended(task)
433
- const sent = await claude.session.send({ to: { agentId: task }, text: (input as SessionSend).text })
459
+ const { type, text } = input as SessionSend
460
+ const sent = await claude.session.send({ to: { agentId: task }, text: type === Note ? modNote(caller, text) : text })
434
461
  if (sent.isDelivered !== true) throw ended(task)
435
462
  return null
436
463
  }
@@ -567,7 +594,7 @@ export function serveConversations({ claude, router, store, project, report }: {
567
594
  if (caller === undefined) throw new Error('cmod hears no caller for this before listener.')
568
595
  const target = String(request['session'])
569
596
  const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
570
- ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))] })
597
+ ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))], refusing: list.filter((each) => each.onFailure === 'refuse') })
571
598
  await publishWatches()
572
599
  return null
573
600
  },
@@ -1,7 +1,7 @@
1
1
  import type { ClassicHookEvent, ClassicHookInputs } from 'claude-code'
2
2
  import { configFolders } from '../records.js'
3
3
  import type { Claude } from './claude.js'
4
- import type { RoutedHook } from './router.js'
4
+ import type { RoutedFailure, RoutedHook } from './router.js'
5
5
  import type { Failure } from './toaster.js'
6
6
  import { callEffects, type CallBase, type FileAccess } from '../utils/call-effects.js'
7
7
  import { dynamicPattern } from '../utils/parse-shell.js'
@@ -51,6 +51,14 @@ export function classicHook<H extends Hook>(event: H, hook: HookListener<H>, rep
51
51
  return routed as unknown as RoutedHook<RoutedEvent>
52
52
  }
53
53
 
54
+ export function classicFailure<H extends Hook>(failed: (input: ClassicHookInputs[H], error: Error) => HookFields | undefined | Promise<HookFields | undefined>): RoutedFailure<RoutedEvent> {
55
+ const routed = async (e: unknown, error: Error, next: (e: unknown) => Promise<unknown>) => {
56
+ const ours = await failed(e as ClassicHookInputs[H], error)
57
+ return ours === undefined ? undefined : mergeClassic((await next(e)) as HookFields, ours)
58
+ }
59
+ return routed as unknown as RoutedFailure<RoutedEvent>
60
+ }
61
+
54
62
  const frontmatter = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/
55
63
 
56
64
  const baseDirectoryLine = /^Base directory for this skill: [^\n]*\n\n/
@@ -1,4 +1,4 @@
1
- import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement, Timer } from 'claude-code'
1
+ import type { Args, ClassicHookInputs, EventResult, Frozen, HookBudget, HookFailure, PluginOptions, RenderElement, Timer } from 'claude-code'
2
2
  import type { Step } from '../installer.js'
3
3
  import type { Allowed, FileChange, Disposable, Imports, Mod, ModDefinition, PaneHandle, SettingsFile, SettingsFileChange, SettingsFileChangeListener, Toast } from '../mod.js'
4
4
  import type { Options, OptionValues } from '../options.js'
@@ -12,7 +12,7 @@ import { bind } from './bound.js'
12
12
  import { serveConversations } from './conversations.js'
13
13
  import { answerCall, answeringOwnCalls, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
14
14
  import { keepEngine } from './engine.js'
15
- import { classicHook, userSkillHook, type Hook, type HookListener, type RoutedEvent } from './hooks.js'
15
+ import { classicFailure, classicHook, userSkillHook, type Hook, type HookFields, type HookListener, type RoutedEvent } from './hooks.js'
16
16
  import type { PermissionRoutes } from './permission-check.js'
17
17
  import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
18
18
  import { cmodError, lastLineOf, locatingPrograms, readLines, type Programs } from './programs.js'
@@ -21,7 +21,7 @@ import { fitsOption } from '../options.js'
21
21
  import { createInstaller } from './installer.js'
22
22
  import { createOptions, MissingOptions } from './options.js'
23
23
  import { createRouter, type Router, type RouterNext } from './router.js'
24
- import { conversationMethods, createSessionsClient } from './sessions.js'
24
+ import { conversationMethods, createSessionsClient, type SessionsClient } from './sessions.js'
25
25
  import type { SessionChange } from '../session/conversation.js'
26
26
  import type { BeforeEvent } from '../session/session.js'
27
27
  import { matches } from '../utils/matcher.js'
@@ -52,6 +52,7 @@ export type Lifecycle<State extends object> = {
52
52
  readonly toasts: readonly ShownToast[]
53
53
  start(claude: Claude, read: (claude: Claude) => Promise<Plugin>): Promise<void>
54
54
  route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
55
+ caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, failure: HookFailure): Promise<EventResult<N>>
55
56
  raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
56
57
  }
57
58
 
@@ -72,7 +73,7 @@ type ActiveMod<State extends object, Declared extends Options> = {
72
73
  readonly mod: Mod<State, Declared>
73
74
  readonly added: readonly string[]
74
75
  readonly imports: Imports
75
- readonly raise: (method: string, payload: Readonly<Record<string, unknown>>) => Promise<unknown>
76
+ readonly raise: SessionsClient['raise']
76
77
  }
77
78
 
78
79
 
@@ -88,7 +89,9 @@ const cmodWaitMs = 60_000
88
89
 
89
90
  const cmodSlowCheckMs = 10_000
90
91
 
91
- const startHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
92
+ const hookMs = 10_000 satisfies HookBudget['ms']
93
+
94
+ const startHoldMs = hookMs - 1_000
92
95
 
93
96
  const dependencyCallMs = 30_000
94
97
 
@@ -169,7 +172,7 @@ export function createLifecycle<State extends object, Declared extends Options =
169
172
  let activation: Promise<void> | undefined
170
173
  let mod: Mod<State> | undefined
171
174
  let imports: Imports | undefined
172
- let raise: ActiveMod<State, Declared>['raise'] | undefined
175
+ let raise: SessionsClient['raise'] | undefined
173
176
  let failure: unknown
174
177
  let pluginOptions = options
175
178
  const installer = createInstaller(definition.name, () => claude())
@@ -628,6 +631,16 @@ export function createLifecycle<State extends object, Declared extends Options =
628
631
  if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined) return answer
629
632
  return runtime.toaster.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.props, render.requestId) as EventResult<N>
630
633
  },
634
+ async caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, { kind, message }: HookFailure): Promise<EventResult<N>> {
635
+ const call = e as Frozen<Args<'cmod.call'>>
636
+ const isAsk = event === 'cmod.call' && call.to === definition.name && beforeAskOf(call) !== undefined
637
+ const reasons: Readonly<Record<string, string>> = { timeout: `it ran past the ${hookMs / 1000} s Claude Code gives a hook`, throw: message ?? 'it threw' }
638
+ const reason = reasons[kind]
639
+ if (reason === undefined) return isAsk ? ({ value: null } as EventResult<N>) : next(e)
640
+ const error = new Error(reason)
641
+ toastFailure({ id: `stopped:${event}`, message: `Claude Code stopped its ${event.replace(/^classic\./, '')} hook`, error })
642
+ return router.dispatchFailure(event, e, error, next)
643
+ },
631
644
  }
632
645
  }
633
646
 
@@ -704,7 +717,8 @@ async function createMod<State extends object, Declared extends Options>(definit
704
717
  }))
705
718
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
706
719
  const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
707
- const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>): Disposable => router.add(`classic.${hook}`, classicHook(hook, listener, report))
720
+ const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>, failed?: (input: ClassicHookInputs[H], error: Error) => HookFields | undefined): Disposable =>
721
+ router.add(`classic.${hook}`, classicHook(hook, listener, report), failed === undefined ? undefined : classicFailure(failed))
708
722
  const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
709
723
  const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
710
724
  read: () => checked.settings.read({ source }),
@@ -718,8 +732,8 @@ async function createMod<State extends object, Declared extends Options>(definit
718
732
  claude: checked,
719
733
  report: (id, message, error) => report({ id, message, error }),
720
734
  isGranted: (permission) => flag(permission),
721
- hookOn: (hook, listener) => hookOn(hook as Hook, listener as HookListener<Hook>),
722
- routeOn: (event, hook) => router.add(event, hook as never),
735
+ hookOn: (hook, listener, failed) => hookOn(hook as Hook, listener as HookListener<Hook>, failed),
736
+ routeOn: (event: RoutedEvent, hook: unknown, failed?: unknown) => router.add(event, hook as never, failed as never),
723
737
  permissionRoutes: runtime.permissionRoutes,
724
738
  })
725
739
  let followed: Parameters<typeof client.follow>[0] | undefined
@@ -944,10 +958,8 @@ async function createMod<State extends object, Declared extends Options>(definit
944
958
  client.changed(changedSession, change, isInProject !== false)
945
959
  return { value: null }
946
960
  }
947
- if (e.method === conversationMethods.before && call.caller === cmodPluginName) {
948
- const { session: asking, event, change, isInProject } = e.input as { readonly session: string; readonly event: BeforeEvent; readonly change: SessionChange; readonly isInProject?: boolean }
949
- return { value: (await client.answerBefore(asking, event, change, isInProject !== false)) ?? null }
950
- }
961
+ const ask = beforeAskOf(call)
962
+ if (ask !== undefined) return { value: (await client.answerBefore(ask.session, ask.event, ask.change, ask.isInProject)) ?? null }
951
963
  if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
952
964
  if (e.method === openPageMethod) {
953
965
  await settingsPages.open(String(e.input))
@@ -955,6 +967,10 @@ async function createMod<State extends object, Declared extends Options>(definit
955
967
  }
956
968
  const served = await conversations?.answer(call)
957
969
  return served ?? answerCall(definition.name, api, e)
970
+ }, (e, error) => {
971
+ if (e.to !== definition.name) return undefined
972
+ const ask = beforeAskOf(e)
973
+ return ask === undefined ? { deny: `${definition.name}: ${error.message}` } : { value: client.failedBefore(ask.session, ask.event, ask.change, ask.isInProject, error) ?? null }
958
974
  })
959
975
  const agents = toolCalls(claude, router)
960
976
  keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined })
@@ -997,6 +1013,14 @@ function checkedMs(name: string, subject: string, ms: number): number {
997
1013
  return ms
998
1014
  }
999
1015
 
1016
+ type BeforeAsk = { readonly session: string; readonly event: BeforeEvent; readonly change: SessionChange; readonly isInProject: boolean }
1017
+
1018
+ function beforeAskOf(call: Frozen<Args<'cmod.call'>> & { readonly caller?: string }): BeforeAsk | undefined {
1019
+ if (call.method !== conversationMethods.before || call.caller !== cmodPluginName) return undefined
1020
+ const { session, event, change, isInProject } = call.input as Omit<BeforeAsk, 'isInProject'> & { readonly isInProject?: boolean }
1021
+ return { session, event, change, isInProject: isInProject !== false }
1022
+ }
1023
+
1000
1024
  async function failsAs(subject: string, step: () => unknown): Promise<void> {
1001
1025
  try {
1002
1026
  await step()
@@ -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,36 +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
12
+ add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>, failed?: RoutedFailure<N>): Disposable
11
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>>
12
15
  }
13
16
 
14
- 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 }
15
18
 
16
19
  export function createRouter(): Router {
17
- const hooks = new Map<RoutedEvent, ErasedHook[]>()
20
+ const routes = new Map<RoutedEvent, Route[]>()
18
21
 
19
22
  return {
20
- add(event, hook) {
21
- const erased = hook as unknown as ErasedHook
22
- 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])
23
26
  return {
24
27
  dispose() {
25
- const chain = (hooks.get(event) ?? []).filter((each) => each !== erased)
26
- if (chain.length === 0) hooks.delete(event)
27
- 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)
28
31
  },
29
32
  }
30
33
  },
31
34
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
32
- const chain = hooks.get(event)
35
+ const chain = routes.get(event)
33
36
  const call = async (index: number, current: unknown): Promise<unknown> => {
34
- const hook = chain?.[index]
35
- if (hook === undefined) return next(current as Frozen<Args<N>>)
36
- 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))
37
40
  }
38
41
  return call(0, e) as Promise<EventResult<N>>
39
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
+ },
40
50
  }
41
51
  }