@cmodjs/core 1.6.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 (136) hide show
  1. package/docs/commands.md +7 -7
  2. package/docs/dependencies.md +1 -1
  3. package/docs/index.md +12 -11
  4. package/docs/jobs.md +12 -16
  5. package/docs/mod.md +111 -36
  6. package/docs/permissions.md +14 -18
  7. package/docs/session.md +171 -82
  8. package/docs/state.md +5 -5
  9. package/docs/testing.md +57 -41
  10. package/docs/ui.md +9 -9
  11. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  12. package/jobs/permissions/decide-permission.js +1 -1
  13. package/jobs/prompt.d.ts +1 -3
  14. package/jobs/prompt.d.ts.map +1 -1
  15. package/jobs/prompt.js +2 -21
  16. package/jobs/slash-command.d.ts +1 -1
  17. package/jobs/slash-command.d.ts.map +1 -1
  18. package/jobs/slash-command.js +1 -1
  19. package/jobs/status-line.d.ts +0 -1
  20. package/jobs/status-line.d.ts.map +1 -1
  21. package/jobs/status-line.js +3 -4
  22. package/mod.d.ts +53 -105
  23. package/mod.d.ts.map +1 -1
  24. package/mod.js +18 -80
  25. package/package.json +1 -1
  26. package/records.d.ts +1 -1
  27. package/records.js +1 -1
  28. package/register.d.ts.map +1 -1
  29. package/register.js +8 -5
  30. package/runtime/before.d.ts +11 -4
  31. package/runtime/before.d.ts.map +1 -1
  32. package/runtime/before.js +131 -67
  33. package/runtime/claude.d.ts +6 -1
  34. package/runtime/claude.d.ts.map +1 -1
  35. package/runtime/conversations.d.ts.map +1 -1
  36. package/runtime/conversations.js +82 -38
  37. package/runtime/deadline.d.ts +17 -0
  38. package/runtime/deadline.d.ts.map +1 -1
  39. package/runtime/deadline.js +34 -0
  40. package/runtime/dependencies.d.ts +2 -0
  41. package/runtime/dependencies.d.ts.map +1 -1
  42. package/runtime/dependencies.js +12 -0
  43. package/runtime/grants.d.ts.map +1 -1
  44. package/runtime/grants.js +13 -8
  45. package/runtime/hooks.d.ts +15 -11
  46. package/runtime/hooks.d.ts.map +1 -1
  47. package/runtime/hooks.js +8 -165
  48. package/runtime/lifecycle.d.ts +4 -2
  49. package/runtime/lifecycle.d.ts.map +1 -1
  50. package/runtime/lifecycle.js +96 -62
  51. package/runtime/permission-check.d.ts +15 -0
  52. package/runtime/permission-check.d.ts.map +1 -0
  53. package/runtime/permission-check.js +37 -0
  54. package/runtime/sessions.d.ts +35 -5
  55. package/runtime/sessions.d.ts.map +1 -1
  56. package/runtime/sessions.js +304 -82
  57. package/runtime/state.d.ts.map +1 -1
  58. package/runtime/state.js +14 -11
  59. package/runtime/tool-calls.d.ts +0 -1
  60. package/runtime/tool-calls.d.ts.map +1 -1
  61. package/runtime/tool-calls.js +0 -19
  62. package/session/context/turn/message/instruction-file.d.ts +9 -0
  63. package/session/context/turn/message/instruction-file.d.ts.map +1 -0
  64. package/session/context/turn/message/instruction-file.js +4 -0
  65. package/session/context/turn/message/message.d.ts +6 -4
  66. package/session/context/turn/message/message.d.ts.map +1 -1
  67. package/session/context/turn/message/message.js +12 -8
  68. package/session/context/turn/message/sender.d.ts +2 -1
  69. package/session/context/turn/message/sender.d.ts.map +1 -1
  70. package/session/context/turn/message/sender.js +4 -1
  71. package/session/context/turn/message/tool-call.d.ts +5 -0
  72. package/session/context/turn/message/tool-call.d.ts.map +1 -1
  73. package/session/context/turn/message/tool-result.d.ts +6 -1
  74. package/session/context/turn/message/tool-result.d.ts.map +1 -1
  75. package/session/context/turn/turn.d.ts +7 -0
  76. package/session/context/turn/turn.d.ts.map +1 -1
  77. package/session/conversation.d.ts +10 -3
  78. package/session/conversation.d.ts.map +1 -1
  79. package/session/conversation.js +14 -5
  80. package/session/goal/goal.d.ts +1 -1
  81. package/session/goal/goal.d.ts.map +1 -1
  82. package/session/goal/task.d.ts +2 -2
  83. package/session/goal/task.d.ts.map +1 -1
  84. package/session/session.d.ts +361 -26
  85. package/session/session.d.ts.map +1 -1
  86. package/session/session.js +40 -0
  87. package/src/jobs/permissions/decide-permission.ts +2 -1
  88. package/src/jobs/prompt.ts +3 -20
  89. package/src/jobs/slash-command.ts +2 -2
  90. package/src/jobs/status-line.ts +4 -7
  91. package/src/mod.ts +52 -147
  92. package/src/records.ts +1 -1
  93. package/src/register.ts +8 -5
  94. package/src/runtime/before.ts +140 -66
  95. package/src/runtime/claude.ts +5 -1
  96. package/src/runtime/conversations.ts +75 -37
  97. package/src/runtime/deadline.ts +38 -0
  98. package/src/runtime/dependencies.ts +12 -0
  99. package/src/runtime/grants.ts +11 -7
  100. package/src/runtime/hooks.ts +24 -163
  101. package/src/runtime/lifecycle.ts +110 -77
  102. package/src/runtime/permission-check.ts +47 -0
  103. package/src/runtime/sessions.ts +334 -91
  104. package/src/runtime/state.ts +15 -8
  105. package/src/runtime/tool-calls.ts +0 -19
  106. package/src/session/context/turn/message/instruction-file.ts +13 -0
  107. package/src/session/context/turn/message/message.ts +17 -11
  108. package/src/session/context/turn/message/sender.ts +5 -2
  109. package/src/session/context/turn/message/tool-call.ts +3 -0
  110. package/src/session/context/turn/message/tool-result.ts +6 -1
  111. package/src/session/context/turn/turn.ts +4 -0
  112. package/src/session/conversation.ts +21 -8
  113. package/src/session/goal/goal.ts +1 -1
  114. package/src/session/goal/task.ts +2 -2
  115. package/src/session/session.ts +227 -24
  116. package/src/testing/claude-code.ts +23 -0
  117. package/src/testing/fake-claude.ts +5 -1
  118. package/src/testing/fake-sessions.ts +38 -24
  119. package/src/testing.ts +88 -19
  120. package/testing/claude-code.d.ts +12 -0
  121. package/testing/claude-code.d.ts.map +1 -0
  122. package/testing/claude-code.js +10 -0
  123. package/testing/fake-claude.d.ts +4 -0
  124. package/testing/fake-claude.d.ts.map +1 -1
  125. package/testing/fake-claude.js +4 -1
  126. package/testing/fake-sessions.d.ts +8 -6
  127. package/testing/fake-sessions.d.ts.map +1 -1
  128. package/testing/fake-sessions.js +35 -20
  129. package/testing.d.ts +38 -9
  130. package/testing.d.ts.map +1 -1
  131. package/testing.js +58 -8
  132. package/docs/hooks.md +0 -259
  133. package/session/context/turn/message/document.d.ts +0 -7
  134. package/session/context/turn/message/document.d.ts.map +0 -1
  135. package/session/context/turn/message/document.js +0 -1
  136. package/src/session/context/turn/message/document.ts +0 -8
@@ -1,6 +1,6 @@
1
1
  import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement, Timer } from 'claude-code'
2
2
  import type { Step } from '../installer.js'
3
- import { hooks, PreToolUse, type Allowed, type Disposable, type Hook, type HookInput, type HookListener, type HookOptions, type Imports, type Mod, type ModDefinition, type PaneHandle, type SettingsFile, type Toast } from '../mod.js'
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'
5
5
  import { changelogPage, consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
6
6
  import { homePrefix, ifMissing, relativePath } from '../utils/paths.js'
@@ -10,9 +10,10 @@ import { beforeDeadline } from './deadline.js'
10
10
  import { announcedOf, announcementMs, announcementOf } from './announce.js'
11
11
  import { bind } from './bound.js'
12
12
  import { serveConversations } from './conversations.js'
13
- import { answerCall, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
13
+ import { answerCall, answeringOwnCalls, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
14
14
  import { keepEngine } from './engine.js'
15
- import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, type RoutedEvent } from './hooks.js'
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'
@@ -51,6 +52,7 @@ export type Lifecycle<State extends object> = {
51
52
  readonly toasts: readonly ShownToast[]
52
53
  start(claude: Claude, read: (claude: Claude) => Promise<Plugin>): Promise<void>
53
54
  route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
55
+ raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
54
56
  }
55
57
 
56
58
  type ModRuntime = {
@@ -62,7 +64,7 @@ type ModRuntime = {
62
64
  readonly steps: Steps
63
65
  readonly granted: ReadonlySet<string>
64
66
  readonly refreshGrants: () => Promise<void>
65
- readonly checksPermissions: () => boolean
67
+ readonly permissionRoutes: () => PermissionRoutes | undefined
66
68
  readonly options: PluginOptions
67
69
  }
68
70
 
@@ -70,6 +72,7 @@ type ActiveMod<State extends object, Declared extends Options> = {
70
72
  readonly mod: Mod<State, Declared>
71
73
  readonly added: readonly string[]
72
74
  readonly imports: Imports
75
+ readonly raise: (method: string, payload: Readonly<Record<string, unknown>>) => Promise<unknown>
73
76
  }
74
77
 
75
78
 
@@ -83,6 +86,8 @@ const cmodCheckMs = 1000
83
86
 
84
87
  const cmodWaitMs = 60_000
85
88
 
89
+ const cmodSlowCheckMs = 10_000
90
+
86
91
  const startHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
87
92
 
88
93
  const dependencyCallMs = 30_000
@@ -151,7 +156,7 @@ async function cmodVersion(claude: Claude): Promise<string | undefined> {
151
156
 
152
157
  export function createLifecycle<State extends object, Declared extends Options = Options>(
153
158
  definition: ModDefinition<State, string, Declared>,
154
- checksPermissions: () => boolean = () => true,
159
+ permissionRoutes: () => PermissionRoutes | undefined = () => undefined,
155
160
  options: PluginOptions = {},
156
161
  asksPerson = true,
157
162
  ): Lifecycle<State> {
@@ -164,6 +169,7 @@ export function createLifecycle<State extends object, Declared extends Options =
164
169
  let activation: Promise<void> | undefined
165
170
  let mod: Mod<State> | undefined
166
171
  let imports: Imports | undefined
172
+ let raise: ActiveMod<State, Declared>['raise'] | undefined
167
173
  let failure: unknown
168
174
  let pluginOptions = options
169
175
  const installer = createInstaller(definition.name, () => claude())
@@ -316,7 +322,7 @@ export function createLifecycle<State extends object, Declared extends Options =
316
322
  const activate = async (): Promise<void> => {
317
323
  if (runtime === undefined || plugin === undefined) return
318
324
  const activeRouter = createRouter()
319
- 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) => {
320
326
  if (error instanceof MissingOptions && (await askOptions(error))) return undefined
321
327
  fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : undefined, begin)
322
328
  throw error
@@ -325,18 +331,25 @@ export function createLifecycle<State extends object, Declared extends Options =
325
331
  const { added } = active
326
332
  mod = active.mod
327
333
  imports = active.imports
334
+ raise = active.raise
328
335
  router = activeRouter
329
336
  phase = 'active'
330
337
  runtime.claude.ui.invalidate('ui.render')
331
338
  void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
332
339
  const missed = missedSessionStart
333
340
  missedSessionStart = undefined
334
- 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) => {
335
343
  toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error })
336
344
  return {}
337
345
  })
338
- if (Object.keys(lateAnswer).length > 0) {
339
- 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' })
340
353
  }
341
354
  const version = plugin.version ?? ''
342
355
  const announced = announcedOf(await runtime.claude.store.get(announcedKey))
@@ -418,27 +431,31 @@ export function createLifecycle<State extends object, Declared extends Options =
418
431
  timer.cancel()
419
432
  longWait.cancel()
420
433
  }
421
- const timer = claude().clock.every(cmodCheckMs, () => {
422
- check ??= cmodVersion(claude())
423
- .then((version) => {
424
- check = undefined
425
- latest = version
426
- if (!fits(version)) return
427
- stop()
428
- return install()
429
- })
430
- .catch((error: unknown) => {
431
- stop()
432
- report(error)
433
- })
434
- })
435
- 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)
436
453
  installing(
437
454
  latest === undefined
438
455
  ? "Still waiting for cmod to download cmod. See cmod's own line."
439
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.`,
440
- ),
441
- )
457
+ )
458
+ })
442
459
  }
443
460
 
444
461
  const bootstrap = async (root: string) => {
@@ -560,6 +577,11 @@ export function createLifecycle<State extends object, Declared extends Options =
560
577
  get toasts() {
561
578
  return runtime?.toaster.shown ?? []
562
579
  },
580
+ async raise(method, payload) {
581
+ if (raise === undefined || imports === undefined) throw new Error(`${definition.name} has not started, so ${method} has no listener yet.`)
582
+ bind(imports)
583
+ return raise(method, payload)
584
+ },
563
585
  async start(claudeCalls, read) {
564
586
  if (runtime !== undefined) return
565
587
  programs = locatingPrograms(claudeCalls)
@@ -586,7 +608,10 @@ export function createLifecycle<State extends object, Declared extends Options =
586
608
  const isCallToThisMod = event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name
587
609
  if (event === 'cmod.call' && !isCallToThisMod) return next(e)
588
610
  await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
589
- 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
+ }
590
615
  if (isCallToThisMod && phase !== 'active') {
591
616
  const isStopped = phase === 'declined' || phase === 'failed'
592
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>
@@ -609,9 +634,8 @@ export function createLifecycle<State extends object, Declared extends Options =
609
634
  async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
610
635
  const { router, toaster } = runtime
611
636
  const report = (failure: Failure) => void toaster.fail(failure)
612
- const claude = withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {}))
637
+ const claude = answeringOwnCalls(withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {})), router)
613
638
  const added: string[] = []
614
- const hooksAdded: Hook[] = []
615
639
  const announce = (feature: string) => {
616
640
  added.push(feature)
617
641
  }
@@ -680,7 +704,7 @@ async function createMod<State extends object, Declared extends Options>(definit
680
704
  }))
681
705
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
682
706
  const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
683
- const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
707
+ const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>): Disposable => router.add(`classic.${hook}`, classicHook(hook, listener, report))
684
708
  const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
685
709
  const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
686
710
  read: () => checked.settings.read({ source }),
@@ -689,8 +713,18 @@ async function createMod<State extends object, Declared extends Options>(definit
689
713
  await claude.cmod.call({ to: cmodPluginName, method: 'updateSettings', input: { mod: definition.name, source, project: modState.root, values } })
690
714
  },
691
715
  })
692
- const client = createSessionsClient({ name: definition.name, claude: checked, report: (id, message, error) => report({ id, message, error }), isGranted: (permission) => flag(permission) })
716
+ const client = createSessionsClient({
717
+ name: definition.name,
718
+ claude: checked,
719
+ report: (id, message, error) => report({ id, message, error }),
720
+ 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),
723
+ permissionRoutes: runtime.permissionRoutes,
724
+ })
693
725
  let followed: Parameters<typeof client.follow>[0] | undefined
726
+ let isSetUp = false
727
+ const watches = new Set<{ readonly paths: readonly string[]; readonly listener: (change: FileChange) => unknown }>()
694
728
  const followFolders = (project: string, folder: string) => {
695
729
  if (followed === undefined) return
696
730
  followed = { ...followed, project, cwd: folder }
@@ -698,7 +732,16 @@ async function createMod<State extends object, Declared extends Options>(definit
698
732
  }
699
733
  const imports: Imports = {
700
734
  sessions: client.sessions,
701
- settings: {
735
+ commands: {
736
+ list: async () => (await checked.command.list()).map(({ name, description, source, plugin }) => ({ name, description, source, ...(plugin === undefined ? {} : { plugin }) })),
737
+ run: async (command, args) => {
738
+ const { text, context } = await checked.command.run({ command, ...(args === undefined ? {} : { args }) })
739
+ return { ...(text === undefined ? {} : { text }), ...(context === undefined || context.length === 0 ? {} : { note: context.join('\n') }) }
740
+ },
741
+ beforeRun: (...args: unknown[]) => client.listen('beforeRun', args),
742
+ onRun: (...args: unknown[]) => client.listen('onRun', args),
743
+ },
744
+ config: {
702
745
  list: async () =>
703
746
  (await checked.config.list()).map(({ key, label, description, kind, value, options, provider, isLocked }) => ({
704
747
  key,
@@ -710,9 +753,21 @@ async function createMod<State extends object, Declared extends Options>(definit
710
753
  ...(options === undefined ? {} : { options }),
711
754
  ...(provider.plugin === 'engine' ? {} : { plugin: provider.plugin }),
712
755
  })),
756
+ },
757
+ settings: {
713
758
  user: settingsFile('user'),
714
759
  project: settingsFile('project'),
715
760
  local: settingsFile('local'),
761
+ beforeFileChange: (matcherOrListener: unknown, maybeListener?: SettingsFileChangeListener) => {
762
+ const listener = (maybeListener ?? matcherOrListener) as SettingsFileChangeListener
763
+ const matcher = maybeListener === undefined ? undefined : matcherOrListener
764
+ return hookOn('ConfigChange', async ({ source, file_path }) => {
765
+ const change: SettingsFileChange = { source: source === 'skills' ? 'skills' : (source.replace('_settings', '') as SettingsFileChange['source']), ...(file_path === undefined ? {} : { path: file_path }) }
766
+ if (matcher !== undefined && !matches(change, matcher)) return undefined
767
+ const refuse = (await listener(change))?.refuse
768
+ return typeof refuse === 'string' ? { block: refuse } : undefined
769
+ })
770
+ },
716
771
  },
717
772
  model: { complete: (completion, options) => checked.model.complete(completion, options) },
718
773
  fs: {
@@ -725,6 +780,20 @@ async function createMod<State extends object, Declared extends Options>(definit
725
780
  const { kind, size, mtimeMs, isLink, realPath } = await checked.fs.stat(expand(path), options)
726
781
  return { isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs, ...(realPath === undefined ? {} : { realPath }) }
727
782
  },
783
+ onChange({ path }, listener) {
784
+ if (isSetUp) throw new Error(`${definition.name}: fs.onChange works only in setup, because Claude Code reads the paths it watches when a session starts.`)
785
+ const paths = [path].flat().map((each) => resolvedPath(each, places))
786
+ const watch = { paths, listener }
787
+ watches.add(watch)
788
+ if (watches.size === 1) {
789
+ hookOn('SessionStart', () => ({ watchPaths: [...new Set([...watches].flatMap((each) => each.paths))] }))
790
+ hookOn('FileChanged', ({ file_path, event }) => {
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
793
+ })
794
+ }
795
+ return { dispose: () => void watches.delete(watch) }
796
+ },
728
797
  },
729
798
  metadata: {
730
799
  read: (path) => files.read(path),
@@ -787,23 +856,10 @@ async function createMod<State extends object, Declared extends Options>(definit
787
856
  files: allowed('files'),
788
857
  config: allowed('config'),
789
858
  },
790
- on(hook: Hook, matcherOrListener: unknown, listenerOrOptions?: unknown, maybeOptions?: HookOptions): Disposable {
791
- const hasMatcher = typeof listenerOrOptions === 'function'
792
- const listener = (hasMatcher ? listenerOrOptions : matcherOrListener) as HookListener<Hook>
793
- const matcher = hasMatcher ? matcherOrListener : undefined
794
- const options = (hasMatcher ? maybeOptions : listenerOrOptions) as HookOptions | undefined
795
- if (!hooks.includes(hook)) throw new Error(`${definition.name}: mod.on takes one of Claude Code's hooks, such as PreToolUse, not ${String(hook)}.`)
796
- const matched: HookListener<Hook> = matcher === undefined ? listener : (input) => (matches(input, matcher) ? listener(input) : undefined)
797
- const bounded = options?.timeoutMs === undefined ? matched : timedHook(hook, matched, checkedMs(definition.name, `the ${hook} hook's timeoutMs`, options.timeoutMs), claude, toaster.toast)
798
- if (!hooksAdded.includes(hook)) hooksAdded.push(hook)
799
- if (hook === PreToolUse) return router.add('tool.call', preToolUseHook(definition.name, bounded as HookListener<typeof PreToolUse>, claude, agents, held))
800
- const classic: Exclude<Hook, typeof PreToolUse> = hook
801
- return router.add(`classic.${classic}`, classicHook(definition.name, classic, bounded as HookListener<typeof classic>, claude, agents, report))
802
- },
803
859
  every: (ms, listener) => timer(ms, 'every', claude.clock.every, listener),
804
860
  after: (ms, listener) => timer(ms, 'after', claude.clock.after, listener),
805
861
  use: (job) => job({ mod, announce, reserveName }),
806
- log: (text) => claude.ui.log(`${definition.name}: ${text}`, { to: 'debug' }),
862
+ log: (text, options) => claude.ui.log(`${definition.name}: ${text}`, { to: options?.to ?? 'debug' }),
807
863
  ui: { ...area.ui, notice: (callId, text) => claude.ui.notice(callId, text) },
808
864
  settings: settingsPages,
809
865
  dependencies: dependencyCalls(claude, (call, task) => beforeDeadline(claude, { ms: dependencyCallMs }, call, task)),
@@ -829,8 +885,8 @@ async function createMod<State extends object, Declared extends Options>(definit
829
885
  loadedCwd = nextCwd
830
886
  if (!hasMovedRoot) area.changed()
831
887
  if (nextCwd === oldCwd) return
832
- const moved: HookInput<'CwdChanged'> = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
833
- await router.dispatch('classic.CwdChanged', moved as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
888
+ const moved = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
889
+ await router.dispatch('classic.CwdChanged', moved as unknown as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
834
890
  } catch (error) {
835
891
  report({ id: 'cmod:follow', message: `Keeps the state of ${modState.root} until the next prompt or folder move`, error })
836
892
  }
@@ -883,8 +939,8 @@ async function createMod<State extends object, Declared extends Options>(definit
883
939
  if (e.to !== definition.name) return next(e)
884
940
  const call = e as typeof e & { readonly caller?: string }
885
941
  if (e.method === conversationMethods.changed && call.caller === cmodPluginName) {
886
- if (!flag('conversation')) return { value: null }
887
942
  const { session: changedSession, change, isInProject } = e.input as { readonly session: string; readonly change: SessionChange; readonly isInProject?: boolean }
943
+ if (changedSession !== client.sessions.current.id && !flag('conversation')) return { value: null }
888
944
  client.changed(changedSession, change, isInProject !== false)
889
945
  return { value: null }
890
946
  }
@@ -915,20 +971,19 @@ async function createMod<State extends object, Declared extends Options>(definit
915
971
  if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
916
972
  await failsAs('the details of its session did not load', () => followCurrent(undefined))
917
973
  await failsAs('its setup function threw', () => definition.setup(mod))
974
+ isSetUp = true
918
975
  const permissionHooks = permissionEvents.filter((event) => router.has(event))
919
- if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
976
+ if (permissionHooks.length > 0 && runtime.permissionRoutes() === undefined) {
920
977
  throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
921
978
  }
922
- if (held !== undefined && hooksAdded.includes(PreToolUse)) router.add('tool.check', heldDecisionHook(held))
923
979
  await failsAs('its open panes did not load', () => area.restorePanes())
924
- if (hooksAdded.length > 0) added.push(`${hooksAdded.length === 1 ? 'a hook' : 'hooks'} on ${listed(hooksAdded)}`)
925
- return { mod, added, imports }
980
+ return { mod, added, imports, raise: client.raise }
926
981
  }
927
982
 
928
983
  function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
929
984
  const bound = Object.entries(keys)
930
985
  if (bound.length === 0) return claude
931
- const register: Claude['command']['register'] = (command) => {
986
+ const addCommand: Claude['command']['register'] = (command) => {
932
987
  for (const [key, commandName] of bound) {
933
988
  if (commandName === command.name && command.immediate !== true) {
934
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 })
@@ -936,7 +991,7 @@ function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string
936
991
  }
937
992
  return claude.command.register(command)
938
993
  }
939
- return { ...claude, command: { ...claude.command, register } }
994
+ return { ...claude, command: { ...claude.command, register: addCommand } }
940
995
  }
941
996
 
942
997
  const longestTimerMs = 2_147_483_647
@@ -946,28 +1001,6 @@ function checkedMs(name: string, subject: string, ms: number): number {
946
1001
  return ms
947
1002
  }
948
1003
 
949
- function timedHook<H extends Hook>(event: H, hook: HookListener<H>, ms: number, claude: Claude, toast: Toast): HookListener<H> {
950
- return (input) =>
951
- new Promise((resolve, reject) => {
952
- const timer = claude.clock.after(ms, () => {
953
- toast.warning(`The ${event} hook passed its ${ms / 1000} s timeout`, { id: `timeout:${event}` })
954
- resolve(undefined)
955
- })
956
- Promise.resolve()
957
- .then(() => hook(input))
958
- .then(
959
- (answer) => {
960
- timer.cancel()
961
- resolve(answer)
962
- },
963
- (error: unknown) => {
964
- timer.cancel()
965
- reject(error)
966
- },
967
- )
968
- })
969
- }
970
-
971
1004
  async function failsAs(subject: string, step: () => unknown): Promise<void> {
972
1005
  try {
973
1006
  await step()
@@ -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
+ }