@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,6 +13,7 @@ import { serveConversations } from './conversations.js'
13
13
  import { answerCall, answeringOwnCalls, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
14
14
  import { keepEngine } from './engine.js'
15
15
  import { classicHook, permissionEvents, userSkillHook, type Hook, type HookListener, type RoutedEvent } from './hooks.js'
16
+ import type { PermissionRoutes } from './permission-check.js'
16
17
  import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
17
18
  import { cmodError, lastLineOf, locatingPrograms, readLines, type Programs } from './programs.js'
18
19
  import { createModFiles, resolvedPath, type Places } from './metadata.js'
@@ -63,7 +64,7 @@ type ModRuntime = {
63
64
  readonly steps: Steps
64
65
  readonly granted: ReadonlySet<string>
65
66
  readonly refreshGrants: () => Promise<void>
66
- readonly checksPermissions: () => boolean
67
+ readonly permissionRoutes: () => PermissionRoutes | undefined
67
68
  readonly options: PluginOptions
68
69
  }
69
70
 
@@ -85,6 +86,8 @@ const cmodCheckMs = 1000
85
86
 
86
87
  const cmodWaitMs = 60_000
87
88
 
89
+ const cmodSlowCheckMs = 10_000
90
+
88
91
  const startHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
89
92
 
90
93
  const dependencyCallMs = 30_000
@@ -153,7 +156,7 @@ async function cmodVersion(claude: Claude): Promise<string | undefined> {
153
156
 
154
157
  export function createLifecycle<State extends object, Declared extends Options = Options>(
155
158
  definition: ModDefinition<State, string, Declared>,
156
- checksPermissions: () => boolean = () => true,
159
+ permissionRoutes: () => PermissionRoutes | undefined = () => undefined,
157
160
  options: PluginOptions = {},
158
161
  asksPerson = true,
159
162
  ): Lifecycle<State> {
@@ -319,7 +322,7 @@ export function createLifecycle<State extends object, Declared extends Options =
319
322
  const activate = async (): Promise<void> => {
320
323
  if (runtime === undefined || plugin === undefined) return
321
324
  const activeRouter = createRouter()
322
- const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), store: plugin.store, steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error: unknown) => {
325
+ const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), store: plugin.store, steps: plugin.steps, granted, refreshGrants, permissionRoutes, options: pluginOptions }).catch(async (error: unknown) => {
323
326
  if (error instanceof MissingOptions && (await askOptions(error))) return undefined
324
327
  fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : undefined, begin)
325
328
  throw error
@@ -335,12 +338,18 @@ export function createLifecycle<State extends object, Declared extends Options =
335
338
  void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
336
339
  const missed = missedSessionStart
337
340
  missedSessionStart = undefined
338
- const lateAnswer = missed === undefined ? {} : await activeRouter.dispatch('classic.SessionStart', missed, async () => ({})).catch((error: unknown) => {
341
+ const replay = (e: Frozen<Args<'classic.SessionStart'>>) => (definition.name === cmodPluginName ? activeRouter.dispatch('classic.SessionStart', e, async () => ({})) : checkedRoute('classic.SessionStart', e, async () => ({})))
342
+ const lateAnswer = missed === undefined ? {} : await replay(missed).catch((error: unknown) => {
339
343
  toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error })
340
344
  return {}
341
345
  })
342
- if (Object.keys(lateAnswer).length > 0) {
343
- runtime.claude.ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read the answer of its SessionStart hooks.`, { to: 'debug' })
346
+ const { additionalContext, ...unread } = lateAnswer as { readonly additionalContext?: readonly string[] } & Record<string, unknown>
347
+ for (const text of additionalContext ?? []) {
348
+ const added = await runtime.claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
349
+ if (added.deny !== undefined) runtime.claude.ui.log(`${definition.name}: Claude Code refused its SessionStart note: ${added.deny}`, { to: 'debug' })
350
+ }
351
+ if (Object.keys(unread).length > 0) {
352
+ runtime.claude.ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read ${Object.keys(unread).join(', ')} from its SessionStart hooks.`, { to: 'debug' })
344
353
  }
345
354
  const version = plugin.version ?? ''
346
355
  const announced = announcedOf(await runtime.claude.store.get(announcedKey))
@@ -422,27 +431,31 @@ export function createLifecycle<State extends object, Declared extends Options =
422
431
  timer.cancel()
423
432
  longWait.cancel()
424
433
  }
425
- const timer = claude().clock.every(cmodCheckMs, () => {
426
- check ??= cmodVersion(claude())
427
- .then((version) => {
428
- check = undefined
429
- latest = version
430
- if (!fits(version)) return
431
- stop()
432
- return install()
433
- })
434
- .catch((error: unknown) => {
435
- stop()
436
- report(error)
437
- })
438
- })
439
- const longWait = claude().clock.after(cmodWaitMs, () =>
434
+ const poll = (ms: number) =>
435
+ claude().clock.every(ms, () => {
436
+ check ??= cmodVersion(claude())
437
+ .then((version) => {
438
+ check = undefined
439
+ latest = version
440
+ if (!fits(version)) return
441
+ stop()
442
+ return install()
443
+ })
444
+ .catch((error: unknown) => {
445
+ stop()
446
+ report(error)
447
+ })
448
+ })
449
+ let timer = poll(cmodCheckMs)
450
+ const longWait = claude().clock.after(cmodWaitMs, () => {
451
+ timer.cancel()
452
+ timer = poll(cmodSlowCheckMs)
440
453
  installing(
441
454
  latest === undefined
442
455
  ? "Still waiting for cmod to download cmod. See cmod's own line."
443
456
  : `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.steps ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`,
444
- ),
445
- )
457
+ )
458
+ })
446
459
  }
447
460
 
448
461
  const bootstrap = async (root: string) => {
@@ -595,7 +608,10 @@ export function createLifecycle<State extends object, Declared extends Options =
595
608
  const isCallToThisMod = event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name
596
609
  if (event === 'cmod.call' && !isCallToThisMod) return next(e)
597
610
  await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
598
- if (event === 'classic.SessionStart' && phase !== 'active') missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
611
+ if (event === 'classic.SessionStart' && phase !== 'active') {
612
+ missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
613
+ return next(e)
614
+ }
599
615
  if (isCallToThisMod && phase !== 'active') {
600
616
  const isStopped = phase === 'declined' || phase === 'failed'
601
617
  return { deny: isStopped ? `${definition.name} is not set up. Its toast says why.` : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
@@ -688,7 +704,7 @@ async function createMod<State extends object, Declared extends Options>(definit
688
704
  }))
689
705
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
690
706
  const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
691
- const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>): Disposable => router.add(`classic.${hook}`, classicHook(definition.name, hook, listener, report))
707
+ const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>): Disposable => router.add(`classic.${hook}`, classicHook(hook, listener, report))
692
708
  const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
693
709
  const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
694
710
  read: () => checked.settings.read({ source }),
@@ -704,6 +720,7 @@ async function createMod<State extends object, Declared extends Options>(definit
704
720
  isGranted: (permission) => flag(permission),
705
721
  hookOn: (hook, listener) => hookOn(hook as Hook, listener as HookListener<Hook>),
706
722
  routeOn: (event, hook) => router.add(event, hook as never),
723
+ permissionRoutes: runtime.permissionRoutes,
707
724
  })
708
725
  let followed: Parameters<typeof client.follow>[0] | undefined
709
726
  let isSetUp = false
@@ -747,8 +764,8 @@ async function createMod<State extends object, Declared extends Options>(definit
747
764
  return hookOn('ConfigChange', async ({ source, file_path }) => {
748
765
  const change: SettingsFileChange = { source: source === 'skills' ? 'skills' : (source.replace('_settings', '') as SettingsFileChange['source']), ...(file_path === undefined ? {} : { path: file_path }) }
749
766
  if (matcher !== undefined && !matches(change, matcher)) return undefined
750
- const answer = await listener(change)
751
- return answer === undefined ? undefined : { decision: 'block', reason: answer.refuse }
767
+ const refuse = (await listener(change))?.refuse
768
+ return typeof refuse === 'string' ? { block: refuse } : undefined
752
769
  })
753
770
  },
754
771
  },
@@ -769,9 +786,10 @@ async function createMod<State extends object, Declared extends Options>(definit
769
786
  const watch = { paths, listener }
770
787
  watches.add(watch)
771
788
  if (watches.size === 1) {
772
- hookOn('SessionStart', () => ({ hookSpecificOutput: { watchPaths: [...new Set([...watches].flatMap((each) => each.paths))] } }))
789
+ hookOn('SessionStart', () => ({ watchPaths: [...new Set([...watches].flatMap((each) => each.paths))] }))
773
790
  hookOn('FileChanged', ({ file_path, event }) => {
774
791
  for (const each of watches) if (each.paths.some((watched) => relativePath(watched, file_path) !== undefined)) void Promise.resolve(each.listener({ path: file_path, type: event })).catch((error: unknown) => report({ id: 'fs:onChange', message: 'The fs.onChange listener failed', error }))
792
+ return undefined
775
793
  })
776
794
  }
777
795
  return { dispose: () => void watches.delete(watch) }
@@ -955,7 +973,7 @@ async function createMod<State extends object, Declared extends Options>(definit
955
973
  await failsAs('its setup function threw', () => definition.setup(mod))
956
974
  isSetUp = true
957
975
  const permissionHooks = permissionEvents.filter((event) => router.has(event))
958
- if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
976
+ if (permissionHooks.length > 0 && runtime.permissionRoutes() === undefined) {
959
977
  throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
960
978
  }
961
979
  await failsAs('its open panes did not load', () => area.restorePanes())
@@ -965,7 +983,7 @@ async function createMod<State extends object, Declared extends Options>(definit
965
983
  function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
966
984
  const bound = Object.entries(keys)
967
985
  if (bound.length === 0) return claude
968
- const register: Claude['command']['register'] = (command) => {
986
+ const addCommand: Claude['command']['register'] = (command) => {
969
987
  for (const [key, commandName] of bound) {
970
988
  if (commandName === command.name && command.immediate !== true) {
971
989
  toast.warning(`${key} runs /${command.name}, which waits for Claude's turn to end`, { id: `key:${key}`, description: `It adds a row to the conversation. Give /${command.name} immediate: true.`, duration: Infinity })
@@ -973,7 +991,7 @@ function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string
973
991
  }
974
992
  return claude.command.register(command)
975
993
  }
976
- return { ...claude, command: { ...claude.command, register } }
994
+ return { ...claude, command: { ...claude.command, register: addCommand } }
977
995
  }
978
996
 
979
997
  const longestTimerMs = 2_147_483_647
@@ -0,0 +1,47 @@
1
+ import type { Disposable } from '../mod.js'
2
+ import type { HookFields } from './hooks.js'
3
+ import { defined, processPayloadOf, recordOf, type HookOn, type RouteOn, type ToolCheck } from './sessions.js'
4
+
5
+ export type PermissionUse = {
6
+ readonly raise: (method: string, payload: HookFields) => Promise<HookFields | undefined>
7
+ readonly hookOn: HookOn
8
+ readonly routeOn: RouteOn
9
+ readonly current: () => { readonly id: string; readonly cwd: string }
10
+ }
11
+
12
+ export type PermissionRoutes = (method: string, use: PermissionUse) => Disposable
13
+
14
+ type Verdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?: string; readonly rule?: string }
15
+
16
+ const verdictName = (decision: Verdict['decision']) => (decision === 'deny' ? 'refuse' : decision)
17
+
18
+ export const permissionRoutes: PermissionRoutes = (method, { raise, hookOn, routeOn, current }) => {
19
+ const isBefore = method.startsWith('before')
20
+ if (method.endsWith('PermissionCheck')) {
21
+ return routeOn('tool.check', async (e: ToolCheck, next) => {
22
+ const below = (await next(e)) as Verdict
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) }
25
+ if (!isBefore) {
26
+ void raise(method, payload)
27
+ return below
28
+ }
29
+ 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
+ })
47
+ }
@@ -12,12 +12,13 @@ import { GoalChange, type Goal } from '../session/goal/goal.js'
12
12
  import { TaskEnd, TaskStart, type Task } from '../session/goal/task.js'
13
13
  import { beforeEvents, processEvents, Session, sessionEvents, type BeforeEvent, type Placement, type BeforeMethods, type Surface, type OnMethods, type ProcessBeforeMethods, type ProcessName, type ProcessOnMethods, type RecordPart, type SearchOptions, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskOwner, type TaskStartArgs, type ToolCallArgs, type Usage } from '../session/session.js'
14
14
  import { matches, sentMatcher, type Matcher, type SentMatcher } from '../utils/matcher.js'
15
- import { foldAnswers, type Answers, type Place } from './before.js'
15
+ import { beforeAnswerKeys, foldAnswers, foldBefore, methodOf, takenAnswer, type Answers, type Place } from './before.js'
16
16
  import type { Claude } from './claude.js'
17
17
  import { cmodPluginName } from './dependencies.js'
18
18
  import { messageOf } from '../utils/text.js'
19
19
  import { listenerOf, PassedTimeout, withinTime, type ListenOptions, type ListenTime } from './deadline.js'
20
- import { callFiles, type Hook, type HookAnswer } from './hooks.js'
20
+ import { callFiles, type HomeFolders, type HookFields } from './hooks.js'
21
+ import type { PermissionRoutes } from './permission-check.js'
21
22
 
22
23
  export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
23
24
 
@@ -50,8 +51,6 @@ const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
50
51
 
51
52
  export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart ? 'tasks' : 'prompt')
52
53
 
53
- const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
54
-
55
54
  export const isAct = (action: string): action is Act => action === 'callTool' || action in actPermissions
56
55
 
57
56
  export function toolPermission(tool: string): string | undefined {
@@ -134,7 +133,9 @@ export function isInScope(scope: Scope, session: string, change: SessionChange,
134
133
  return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
135
134
  }
136
135
 
137
- export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean; readonly hookOn?: (hook: string, listener: (input: unknown) => unknown) => Disposable; readonly routeOn?: RouteOn }): SessionsClient {
136
+ export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>) => Disposable
137
+
138
+ 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 {
138
139
  const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
139
140
  const listening = new Map<string, Set<Listening>>()
140
141
  const deciding = new Map<string, Set<Listening>>()
@@ -197,6 +198,13 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
197
198
 
198
199
 
199
200
 
201
+ const invoke = (method: string, options: ListenOptions, run: (time: ListenTime) => unknown, takes: readonly string[] = []): Promise<unknown> =>
202
+ withinTime(claude.clock, method, options, run).catch((error: unknown) => {
203
+ if (error instanceof PassedTimeout && !method.startsWith('before')) return undefined
204
+ report(`session:${method}`, `The ${method} listener failed`, error)
205
+ return options.onFailure === 'refuse' && takes.includes('refuse') ? { refuse: `${name}: ${messageOf(error)}` } : undefined
206
+ })
207
+
200
208
  const combined = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<Partial<Answers> | undefined> => {
201
209
  const askers = [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])]
202
210
  .filter((entry) => entry.event === event)
@@ -204,15 +212,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
204
212
  if (!isInScope(entry.scope, session, latest, isUnderIn(session))) return undefined
205
213
  if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
206
214
  const payload = await payloadFor(session, latest)
207
- const answer = await withinTime(claude.clock, entry.method, entry.options, (time) => entry.listener(payload as never, time)).catch((error: unknown) => {
208
- if (entry.options.onFailure !== 'refuse') throw error
209
- report(`session:${entry.method}`, `The ${entry.method} listener failed`, error)
210
- return { refuse: `${name}: ${messageOf(error)}` }
211
- })
215
+ const answer = await invoke(entry.method, entry.options, async (time) => takenAnswer(entry.method, beforeAnswerKeys[event], await entry.listener(payload as never, time)), beforeAnswerKeys[event])
212
216
  if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
213
217
  throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
214
218
  })
215
- return foldAnswers(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
219
+ return foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
216
220
  }
217
221
 
218
222
  const eventMethods = (scopeOf: () => Scope, hasBefore: boolean, ended?: string): OnMethods & Partial<BeforeMethods> => {
@@ -226,9 +230,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
226
230
  ]) as OnMethods & Partial<BeforeMethods>
227
231
  }
228
232
 
233
+ let folders: Promise<HomeFolders> | undefined
229
234
  const withFiles = async (message: Message): Promise<Message> => {
230
235
  if (message.type !== ToolCall && message.type !== ToolResult) return message
231
- return { ...message, files: await callFiles(name, claude, { tool: message.name, input: message.input }, message.cwd) }
236
+ folders ??= Promise.all([claude.env.home(), claude.env.configHome()]).then(([home, configRoot]) => ({ home, configRoot }))
237
+ return { ...message, files: await callFiles(name, claude, folders, { tool: message.name, input: message.input }, message.cwd) }
232
238
  }
233
239
 
234
240
  const messagesFrom = async (method: string, input: unknown) => Promise.all((await call<Message[]>(method, input)).map(withFiles))
@@ -426,39 +432,17 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
426
432
  const processListeners = new Map<string, Set<ProcessListener>>()
427
433
 
428
434
  const raise = async (method: string, payload: HookFields): Promise<HookFields | undefined> => {
429
- const isBefore = method.startsWith('before')
430
435
  const heard = [...(processListeners.get(method) ?? [])].filter(({ matcher }) => payload['task'] === undefined && (matcher === undefined || matches(payload, matcher)))
431
- if (!isBefore) {
432
- await Promise.all(
433
- heard.map(({ listener, options }) =>
434
- withinTime(claude.clock, method, options, (time) => listener(payload, time)).catch((error: unknown) => {
435
- if (!(error instanceof PassedTimeout)) report(`session:${method}`, `The ${method} listener failed`, error)
436
- }),
437
- ),
438
- )
436
+ if (!method.startsWith('before')) {
437
+ await Promise.all(heard.map(({ listener, options }) => invoke(method, options, (time) => listener(payload, time))))
439
438
  return undefined
440
439
  }
440
+ const failing = method === 'beforePermissionRequest' ? { onFailure: 'refuse' as const } : {}
441
441
  const takes = answerKeys[method.slice('before'.length)] ?? []
442
- let merged: HookFields | undefined
443
- for (const { listener, options } of heard) {
444
- try {
445
- const answer = (await withinTime(claude.clock, method, options, (time) => listener(payload, time))) as HookFields | null | undefined
446
- if (answer === undefined || answer === null) continue
447
- const unread = Object.keys(answer).filter((key) => !takes.includes(key))
448
- 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'}.`)
449
- const notes = [merged?.['note'], answer['note']].filter((note): note is string => typeof note === 'string')
450
- merged = { ...merged, ...answer, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
451
- if (typeof answer['refuse'] === 'string') return merged
452
- } catch (error) {
453
- report(`session:${method}`, `The ${method} listener failed`, error)
454
- if (options.onFailure === 'refuse' || method === 'beforePermissionRequest') return { refuse: `${name}: ${messageOf(error)}` }
455
- }
456
- }
457
- return merged
442
+ const askers = heard.map(({ listener, options }) => () => invoke(method, { ...options, ...failing }, async (time) => takenAnswer(method, takes, await listener(payload, time)), takes))
443
+ return foldAnswers<undefined, HookFields>(method, takes, ['refuse'], undefined, askers, (error) => report(`session:${method}`, `The ${method} listener failed`, error))
458
444
  }
459
445
 
460
- const permissionCheck = (e: ToolCheck, below: ToolCheckVerdict): HookFields => ({ session: current.id, cwd: current.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) })
461
-
462
446
  const commandRun = async (e: Args<'command.run'>): Promise<HookFields> => {
463
447
  const source = (await claude.command.list()).find((command) => command.name === e.command)?.source
464
448
  return { session: current.id, cwd: current.cwd, name: e.command, args: e.args, ...defined({ source }), from: senderOf(e.origin, false) }
@@ -483,30 +467,21 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
483
467
  const listenTo = (method: string, named: string) => {
484
468
  if (hookOn === undefined || routeOn === undefined) throw new Error(`${name}: ${method} works only in a mod's own session.`)
485
469
  const isBefore = method.startsWith('before')
486
- if (named === 'PermissionCheck') {
487
- return routeOn('tool.check', async (e: ToolCheck, next) => {
488
- const below = (await next(e)) as ToolCheckVerdict
489
- if (!isBefore) {
490
- void raise(method, permissionCheck(e, below))
491
- return below
492
- }
493
- const answer = await raise(method, permissionCheck(e, below))
494
- if (answer === undefined || below.decision === 'deny') return below
495
- if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
496
- if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
497
- return answer['allow'] === true ? { decision: 'allow' } : below
498
- })
470
+ if (named === 'PermissionCheck' || named === 'PermissionRequest') {
471
+ 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)
499
474
  }
500
475
  const event: { readonly on: string; readonly before?: string } = processEvents[named as ProcessName]
501
476
  const hook = (isBefore ? event.before : undefined) ?? event.on
502
477
  return hookOn(hook, async (input: unknown) => {
503
- const payload = processPayloadOf(named as Exclude<ProcessName, 'PermissionCheck'>, input)
478
+ const payload = processPayloadOf(named as ProcessFieldsName, input)
504
479
  if (!isBefore) {
505
480
  if (named !== 'SessionStart' || payload['source'] !== 'compact') void raise(method, payload)
506
481
  return undefined
507
482
  }
508
483
  const answer = await raise(method, payload)
509
- return answer === undefined ? undefined : hookAnswerOf(named, hook, answer)
484
+ return answer === undefined ? undefined : hookAnswerOf(named, answer)
510
485
  })
511
486
  }
512
487
 
@@ -550,14 +525,14 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
550
525
  },
551
526
  changed(session, change, isInProject) {
552
527
  remember(session, change)
528
+ let payload: Promise<unknown> | undefined
553
529
  for (const key of isInProject ? [session, everySession] : [session]) {
554
530
  for (const { scope, event, method, matcher, listener, options } of listening.get(key) ?? []) {
555
531
  if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session))) continue
556
- void payloadFor(session, change)
557
- .then((payload) => withinTime(claude.clock, method, options, (time) => listener(payload as never, time)))
558
- .catch((error: unknown) => {
559
- if (!(error instanceof PassedTimeout)) report(`session:${method}`, `The ${method} listener failed`, error)
560
- })
532
+ payload ??= payloadFor(session, change)
533
+ void payload
534
+ .then((heard) => invoke(method, options, (time) => listener(heard as never, time)))
535
+ .catch((error: unknown) => report(`session:${method}`, `The ${method} listener failed`, error))
561
536
  }
562
537
  }
563
538
  },
@@ -583,22 +558,16 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
583
558
  }
584
559
  }
585
560
 
586
- type HookFields = Readonly<Record<string, unknown>>
587
-
588
- type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
589
-
590
- type ToolCheckVerdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?: string; readonly rule?: string }
561
+ export type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
591
562
 
592
563
  export type RouteOn = {
593
564
  (event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>): Disposable
594
565
  (event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>): Disposable
595
566
  }
596
567
 
597
- const verdictName = (decision: 'allow' | 'ask' | 'deny') => (decision === 'deny' ? 'refuse' : decision)
568
+ export const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
598
569
 
599
- const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
600
-
601
- const defined = (fields: HookFields): HookFields => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
570
+ export const defined = (fields: HookFields): HookFields => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
602
571
 
603
572
  const placementOf = (input: HookFields): Placement => defined({ session: input['session_id'], task: input['agent_id'], agentType: input['agent_type'], cwd: input['cwd'], permissionMode: input['permission_mode'] }) as Placement
604
573
 
@@ -635,7 +604,9 @@ const answerKeys: Readonly<Record<string, readonly string[]>> = {
635
604
 
636
605
  const todoOf = (input: HookFields) => ({ id: input['task_id'], subject: input['task_subject'], description: input['task_description'], teammate: input['teammate_name'] })
637
606
 
638
- const processFields: { readonly [Name in Exclude<keyof typeof processEvents, 'PermissionCheck'>]: (input: HookFields) => HookFields } = {
607
+ type ProcessFieldsName = Exclude<ProcessName, 'PermissionCheck'>
608
+
609
+ const processFields: { readonly [Name in ProcessFieldsName]: (input: HookFields) => HookFields } = {
639
610
  ProjectSetup: (input) => ({ trigger: input['trigger'] }),
640
611
  SessionStart: (input) => ({
641
612
  source: input['source'],
@@ -683,28 +654,24 @@ export async function spawnTask(claude: Pick<Claude, 'agent'>, task: TaskStartAr
683
654
  return { id: started.agentId, title: task.title, agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() }
684
655
  }
685
656
 
686
- export function processPayloadOf(named: Exclude<keyof typeof processEvents, 'PermissionCheck'>, input: unknown): HookFields {
657
+ export function processPayloadOf(named: ProcessFieldsName, input: unknown): HookFields {
687
658
  const fields = recordOf(input)
688
659
  return { ...placementOf(fields), ...defined(processFields[named](fields)) }
689
660
  }
690
661
 
691
- export function hookAnswerOf(named: string, hook: string, answer: HookFields): HookAnswer | undefined {
692
- const output = (fields: HookFields): HookAnswer => ({ hookSpecificOutput: { hookEventName: hook as Hook, ...defined(fields) } })
662
+ function hookAnswerOf(named: string, answer: HookFields): HookFields | undefined {
693
663
  const refuse = typeof answer['refuse'] === 'string' ? answer['refuse'] : undefined
694
- if (named === 'SessionStart') return output({ additionalContext: answer['note'], sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'], initialUserMessage: answer['firstPrompt'] })
664
+ const note = typeof answer['note'] === 'string' ? [answer['note']] : undefined
665
+ if (named === 'SessionStart') return defined({ additionalContext: note, sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'] === true ? true : undefined, initialUserMessage: answer['firstPrompt'] })
695
666
  if (named === 'ModelChange') {
696
- if (refuse !== undefined) return output({ permissionDecision: 'deny', permissionDecisionReason: refuse })
697
- return typeof answer['ask'] === 'string' ? output({ permissionDecision: 'ask', permissionDecisionReason: answer['ask'] }) : undefined
698
- }
699
- if (named === 'WorktreeCreate') return output({ worktreePath: answer['result'] })
700
- if (named === 'PermissionRequest') {
701
- if (refuse !== undefined) return output({ decision: defined({ behavior: 'deny', message: refuse, interrupt: answer['interrupt'] }) })
702
- return answer['allow'] === true ? output({ decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) }) : undefined
667
+ if (refuse !== undefined) return { permissionDecision: 'deny', permissionDecisionReason: refuse }
668
+ return typeof answer['ask'] === 'string' ? { permissionDecision: 'ask', permissionDecisionReason: answer['ask'] } : undefined
703
669
  }
704
- if (named === 'PermissionDenial') return output({ retry: answer['retry'] })
705
- if (named === 'ToolBatch') return output({ additionalContext: answer['note'] })
706
- if (named === 'ReplyDisplay') return output({ displayContent: answer['text'] })
707
- return refuse === undefined ? undefined : { decision: 'block', reason: refuse }
670
+ if (named === 'WorktreeCreate') return defined({ worktreePath: answer['result'] })
671
+ if (named === 'PermissionDenial') return answer['retry'] === true ? { retry: true } : undefined
672
+ if (named === 'ToolBatch') return defined({ additionalContext: note })
673
+ if (named === 'ReplyDisplay') return defined({ displayContent: answer['text'] })
674
+ return refuse === undefined ? undefined : { block: refuse }
708
675
  }
709
676
 
710
677
  export function resultOf(where: Pick<Place, 'session' | 'context' | 'turn'>, call: ToolCallArgs & { readonly cwd: string }, answered: Awaited<ReturnType<Claude['tool']['call']>>): ToolResult {
@@ -84,14 +84,19 @@ export function createState<State extends object>({ name, initial, session, root
84
84
 
85
85
  const forgetEndedSessions = async (current: string) => {
86
86
  const stored = await claude.store.keys()
87
- for (const recorded of stored.filter((key) => key.startsWith(transcriptsKey))) {
88
- const owner = recorded.slice(transcriptsKey.length)
89
- if (owner === current) continue
87
+ const ended = async (recorded: string) => {
90
88
  const transcript = await claude.store.get(recorded)
91
- if (typeof transcript === 'string' && (await claude.fs.exists(transcript))) continue
92
- for (const value of stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`))) await claude.store.delete(value)
93
- await claude.store.delete(recorded)
89
+ return typeof transcript !== 'string' || !(await claude.fs.exists(transcript))
94
90
  }
91
+ const owners = stored.filter((key) => key.startsWith(transcriptsKey) && key !== `${transcriptsKey}${current}`)
92
+ await Promise.all(
93
+ owners.map(async (recorded) => {
94
+ if (!(await ended(recorded))) return
95
+ const owner = recorded.slice(transcriptsKey.length)
96
+ await Promise.all(stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`)).map((value) => claude.store.delete(value)))
97
+ await claude.store.delete(recorded)
98
+ }),
99
+ )
95
100
  }
96
101
 
97
102
  const loadGroups = async (only: readonly Lifetime[], next: { readonly root: string; readonly session: string }) => {
@@ -151,8 +156,10 @@ export function createState<State extends object>({ name, initial, session, root
151
156
  moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId }))),
152
157
  switchSession: (nextSession, source, transcript) =>
153
158
  inTurn(async () => {
154
- await claude.store.set(`${transcriptsKey}${nextSession}`, transcript)
155
- await forgetEndedSessions(nextSession)
159
+ if (Object.keys(declared.session).length > 0) {
160
+ await claude.store.set(`${transcriptsKey}${nextSession}`, transcript)
161
+ void forgetEndedSessions(nextSession).catch((error: unknown) => report({ id: 'state:sessions', message: 'Could not forget the session values of ended sessions', error }))
162
+ }
156
163
  if (nextSession === sessionId) return
157
164
  if (source === 'fork') await copySession(sessionId, nextSession)
158
165
  return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession })
@@ -50,7 +50,11 @@ export const skillPrefix = 'Base directory for this skill:'
50
50
 
51
51
  const rowTypes: readonly string[] = ['user', 'assistant', 'attachment', 'system']
52
52
 
53
- const memoryPath = /^Contents of (.+?) \(/
53
+ const memoryPath = /^Contents of (.+?)(?: \([^)\n]*\))?:\r?$/m
54
+
55
+ export function instructionPathOf(text: string): string {
56
+ return memoryPath.exec(text)?.[1] ?? ''
57
+ }
54
58
 
55
59
  export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefined {
56
60
  const { uuid, timestamp, type } = row
@@ -77,7 +81,7 @@ export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefin
77
81
  export function rowOfAppend(append: { readonly uuid: string; readonly door: string; readonly origin: unknown; readonly message: { readonly type: Row['type']; readonly name?: string; readonly isMeta?: true; readonly content: readonly unknown[] } }, at: string, cwd: string): Row {
78
82
  const { message, origin } = append
79
83
  const text = textOf(message.content)
80
- const path = message.type === 'attachment' ? memoryPath.exec(text)?.[1] : undefined
84
+ const path = message.type === 'attachment' ? instructionPathOf(text) || undefined : undefined
81
85
  const model = isObject(origin) && origin['kind'] === 'model' && typeof origin['model'] === 'string' ? origin['model'] : undefined
82
86
  return {
83
87
  id: append.uuid,
package/src/testing.ts CHANGED
@@ -23,7 +23,8 @@ import type { Options, OptionValues } from './options.js'
23
23
  import { readSteps, storeFolder, type Steps } from './records.js'
24
24
  import type { Claude } from './runtime/claude.js'
25
25
  import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
26
- import { foldAnswers, installBefore } from './runtime/before.js'
26
+ import { foldBefore, installBefore } from './runtime/before.js'
27
+ import { permissionRoutes } from './runtime/permission-check.js'
27
28
  import type { RoutedEvent } from './runtime/hooks.js'
28
29
  import { keepClaudeCode, type ClaudeCode } from './testing/claude-code.js'
29
30
  import { createRouter } from './runtime/router.js'
@@ -174,7 +175,7 @@ export function testMod<State extends object, Declared extends Options = Options
174
175
  const starting = options.state as Record<string, object> | undefined
175
176
  const declared = Object.entries(definition.state ?? {}) as [string, object][]
176
177
  const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }])) as State
177
- const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () => true, (options.options ?? {}) as PluginOptions, false)
178
+ const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () => permissionRoutes,(options.options ?? {}) as PluginOptions, false)
178
179
  let started: Promise<void> | undefined
179
180
 
180
181
  const start = async () => {
@@ -212,7 +213,7 @@ export function testMod<State extends object, Declared extends Options = Options
212
213
  hooks: {
213
214
  place: async () => conversations.place(),
214
215
  wants: async (event) => conversations.wants(name, event),
215
- decide: (event, change) => foldAnswers(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
216
+ decide: (event, change) => foldBefore(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
216
217
  },
217
218
  })
218
219