@cmodjs/core 2.0.0 → 2.1.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 (58) hide show
  1. package/docs/commands.md +4 -4
  2. package/docs/dependencies.md +1 -1
  3. package/docs/jobs.md +1 -1
  4. package/docs/mod.md +2 -3
  5. package/docs/permissions.md +1 -1
  6. package/docs/session.md +3 -1
  7. package/docs/testing.md +2 -1
  8. package/jobs/permissions.d.ts.map +1 -1
  9. package/jobs/permissions.js +4 -1
  10. package/package.json +1 -1
  11. package/records.d.ts +1 -1
  12. package/records.js +1 -1
  13. package/register.d.ts.map +1 -1
  14. package/register.js +5 -4
  15. package/runtime/before.d.ts +8 -4
  16. package/runtime/before.d.ts.map +1 -1
  17. package/runtime/before.js +63 -39
  18. package/runtime/conversations.d.ts.map +1 -1
  19. package/runtime/conversations.js +38 -13
  20. package/runtime/engine.d.ts +1 -0
  21. package/runtime/engine.d.ts.map +1 -1
  22. package/runtime/hooks.d.ts +9 -23
  23. package/runtime/hooks.d.ts.map +1 -1
  24. package/runtime/hooks.js +4 -59
  25. package/runtime/lifecycle.d.ts +2 -1
  26. package/runtime/lifecycle.d.ts.map +1 -1
  27. package/runtime/lifecycle.js +36 -23
  28. package/runtime/permission-check.d.ts +15 -0
  29. package/runtime/permission-check.d.ts.map +1 -0
  30. package/runtime/permission-check.js +37 -0
  31. package/runtime/router.d.ts +0 -1
  32. package/runtime/router.d.ts.map +1 -1
  33. package/runtime/router.js +0 -1
  34. package/runtime/sessions.d.ts +13 -8
  35. package/runtime/sessions.d.ts.map +1 -1
  36. package/runtime/sessions.js +44 -80
  37. package/runtime/state.d.ts.map +1 -1
  38. package/runtime/state.js +14 -11
  39. package/session/context/turn/message/message.d.ts +1 -0
  40. package/session/context/turn/message/message.d.ts.map +1 -1
  41. package/session/context/turn/message/message.js +5 -2
  42. package/src/jobs/permissions.ts +3 -1
  43. package/src/records.ts +1 -1
  44. package/src/register.ts +5 -4
  45. package/src/runtime/before.ts +66 -42
  46. package/src/runtime/conversations.ts +35 -13
  47. package/src/runtime/engine.ts +1 -0
  48. package/src/runtime/hooks.ts +13 -86
  49. package/src/runtime/lifecycle.ts +51 -37
  50. package/src/runtime/permission-check.ts +47 -0
  51. package/src/runtime/router.ts +0 -2
  52. package/src/runtime/sessions.ts +55 -84
  53. package/src/runtime/state.ts +15 -8
  54. package/src/session/context/turn/message/message.ts +6 -2
  55. package/src/testing.ts +17 -10
  56. package/testing.d.ts +7 -2
  57. package/testing.d.ts.map +1 -1
  58. package/testing.js +13 -7
@@ -9,7 +9,7 @@ import { GoalChange } from '../session/goal/goal.js'
9
9
  import { receivedMatcher } from '../utils/matcher.js'
10
10
  import { ifMissing } from '../utils/paths.js'
11
11
  import { formatExit, messageOf } from '../utils/text.js'
12
- import { foldAnswers, installBefore, relayOf, type Place } from './before.js'
12
+ import { foldBefore, installBefore, relayOf, type Place } from './before.js'
13
13
  import type { Claude } from './claude.js'
14
14
  import { cmodPluginName } from './dependencies.js'
15
15
  import { readLines, runCmod } from './programs.js'
@@ -80,7 +80,7 @@ export function serveConversations({ claude, router, store, project, report }: {
80
80
  router.add('classic.StopFailure', async (e, next) => endTurn({ reason: 'error', failure: { type: String(e.error), text: e.error_details ?? e.last_assistant_message ?? String(e.error) } })(await next(e)))
81
81
  router.add('classic.SubagentStop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) }, e.agent_id)(await next(e)))
82
82
 
83
- const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], session === '' ? await claude.session.id() : session)) as Value
83
+ const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], await claude.session.id())) as Value
84
84
 
85
85
  const isGranted = async (caller: string | undefined, item: string) => {
86
86
  if (caller === cmodPluginName) return true
@@ -90,6 +90,7 @@ export function serveConversations({ claude, router, store, project, report }: {
90
90
  return (parseConsent(JSON.parse(text), path)[caller] ?? []).includes(item)
91
91
  }
92
92
 
93
+
93
94
  const ended = (id: string) => new Error(`session ${id} has ended`)
94
95
 
95
96
  const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
@@ -144,17 +145,36 @@ export function serveConversations({ claude, router, store, project, report }: {
144
145
  if (sent.isDelivered !== true) throw ended(to)
145
146
  }
146
147
 
148
+ const waitingOn = new Map<Asked, string>()
149
+ let liveness: { cancel(): void } | undefined
150
+ let isChecking = false
151
+ const checkLiveness = async () => {
152
+ if (isChecking) return
153
+ isChecking = true
154
+ try {
155
+ const waiting = [...waitingOn]
156
+ const running = new Set((await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).map((each) => each.id))
157
+ for (const [settle, to] of waiting) if (!running.has(to)) settle.reject(ended(to))
158
+ } finally {
159
+ isChecking = false
160
+ }
161
+ }
162
+ const watchLife = (settle: Asked, to: string) => {
163
+ waitingOn.set(settle, to)
164
+ liveness ??= claude.clock.every(livenessMs, () => void checkLiveness().catch((error: unknown) => report('liveness', 'Could not check which sessions still run', error)))
165
+ return {
166
+ cancel() {
167
+ waitingOn.delete(settle)
168
+ if (waitingOn.size > 0) return
169
+ liveness?.cancel()
170
+ liveness = undefined
171
+ },
172
+ }
173
+ }
174
+
147
175
  const ask = (to: string, relay: Question, deadlineMs?: number) =>
148
176
  new Promise<unknown>((resolve, reject) => {
149
177
  const id = `${session}:${(asks += 1)}`
150
- const timer =
151
- deadlineMs === undefined
152
- ? claude.clock.every(livenessMs, () => {
153
- void isRunning(to).then((isLive) => {
154
- if (!isLive) settle.reject(ended(to))
155
- })
156
- })
157
- : claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
158
178
  const settle: Asked = {
159
179
  resolve(value) {
160
180
  if (asked.delete(id)) timer.cancel()
@@ -165,6 +185,7 @@ export function serveConversations({ claude, router, store, project, report }: {
165
185
  reject(error)
166
186
  },
167
187
  }
188
+ const timer = deadlineMs === undefined ? watchLife(settle, to) : claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
168
189
  asked.set(id, settle)
169
190
  post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
170
191
  })
@@ -244,7 +265,7 @@ export function serveConversations({ claude, router, store, project, report }: {
244
265
  return deciders(event).length > 0
245
266
  },
246
267
  decide: (event, change) =>
247
- foldAnswers(
268
+ foldBefore(
248
269
  event,
249
270
  change,
250
271
  deciders(event).map((watcher) => (latest: SessionChange) => (watcher.from === session ? askHere(watcher.mod, event, latest) : askAway(watcher, event, latest))),
@@ -556,7 +577,7 @@ export function serveConversations({ claude, router, store, project, report }: {
556
577
  const action = String(request['action']) as Act
557
578
  const task = typeof request['task'] === 'string' ? request['task'] : undefined
558
579
  if (!isAct(action)) throw new Error(`cmod takes no action named ${action}.`)
559
- if (target === session) return perform(caller, action, request['input'], task)
580
+ if (target === (await claude.session.id())) return perform(caller, action, request['input'], task)
560
581
  return actElsewhere(caller, target, action, request['input'], task)
561
582
  },
562
583
  }
@@ -572,7 +593,8 @@ export function serveConversations({ claude, router, store, project, report }: {
572
593
  const method = methods[e.method]
573
594
  if (method === undefined) return undefined
574
595
  const input = isObject(e.input) ? e.input : {}
575
- if (e.method !== conversationMethods.act && input['session'] !== session && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
596
+ const isDropping = (e.method === conversationMethods.subscribe || e.method === conversationMethods.watchBefore) && Array.isArray(input['subscriptions']) && input['subscriptions'].length === 0
597
+ if (e.method !== conversationMethods.act && !isDropping && input['session'] !== (await claude.session.id()) && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
576
598
  try {
577
599
  return { value: await method(input, e.caller) }
578
600
  } catch (error) {
@@ -8,6 +8,7 @@ export type Engine = {
8
8
  readonly router: Router
9
9
  readonly toolCalls: ToolCalls
10
10
  readonly report: (failure: Failure) => void
11
+ readonly hasPermissionCheck: () => boolean
11
12
  }
12
13
 
13
14
  const engines = new WeakMap<object, Engine>()
@@ -1,4 +1,4 @@
1
- import type { ClassicHookEvent, ClassicHookInputs, PermissionRequestDecision } from 'claude-code'
1
+ import type { ClassicHookEvent, ClassicHookInputs } from 'claude-code'
2
2
  import { configFolders } from '../records.js'
3
3
  import type { Claude } from './claude.js'
4
4
  import type { RoutedHook } from './router.js'
@@ -10,26 +10,9 @@ import { messageOf } from '../utils/text.js'
10
10
 
11
11
  export type Hook = Exclude<ClassicHookEvent, 'PreToolUse'>
12
12
 
13
- export type HookAnswer = {
14
- decision?: 'block'
15
- reason?: string
16
- hookSpecificOutput?: {
17
- hookEventName?: Hook
18
- additionalContext?: string
19
- permissionDecision?: 'deny' | 'ask'
20
- permissionDecisionReason?: string
21
- sessionTitle?: string
22
- initialUserMessage?: string
23
- watchPaths?: string[]
24
- reloadSkills?: boolean
25
- decision?: PermissionRequestDecision
26
- retry?: boolean
27
- displayContent?: string
28
- worktreePath?: string
29
- }
30
- }
13
+ export type HookFields = Readonly<Record<string, unknown>>
31
14
 
32
- export type HookListener<H extends Hook> = (input: ClassicHookInputs[H]) => HookAnswer | void | Promise<HookAnswer | void>
15
+ export type HookListener<H extends Hook> = (input: ClassicHookInputs[H]) => HookFields | undefined | Promise<HookFields | undefined>
33
16
 
34
17
  export type RoutedEvent =
35
18
  | `classic.${Hook}`
@@ -52,63 +35,18 @@ export type RoutedEvent =
52
35
  | 'agent.spawn'
53
36
  | 'turn.start'
54
37
 
55
- export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
56
-
57
- type ClassicFields = Record<string, unknown>
58
-
59
38
  type CallFiles = { readonly read: string[]; readonly changed: string[] }
60
39
 
61
- const flags: readonly string[] = ['reloadSkills', 'retry']
62
-
63
- const readFields: Record<Hook, readonly string[]> = {
64
- Setup: ['additionalContext'],
65
- SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
66
- SessionEnd: [],
67
- UserPromptSubmit: ['additionalContext', 'sessionTitle'],
68
- UserPromptExpansion: ['additionalContext'],
69
- InstructionsLoaded: [],
70
- ConfigChange: [],
71
- DirectoryAdded: [],
72
- PreModelSwitch: ['permissionDecision', 'permissionDecisionReason'],
73
- PostModelSwitch: ['additionalContext'],
74
- PermissionRequest: ['decision'],
75
- PermissionDenied: ['retry'],
76
- PostToolUse: ['additionalContext'],
77
- PostToolUseFailure: ['additionalContext'],
78
- PostToolBatch: ['additionalContext'],
79
- MessageDisplay: ['displayContent'],
80
- SubagentStart: ['additionalContext'],
81
- SubagentStop: ['additionalContext'],
82
- TaskCreated: [],
83
- TaskCompleted: [],
84
- TeammateIdle: [],
85
- Elicitation: [],
86
- ElicitationResult: [],
87
- Notification: [],
88
- PreCompact: [],
89
- PostCompact: [],
90
- Stop: ['additionalContext'],
91
- StopFailure: [],
92
- CwdChanged: [],
93
- FileChanged: [],
94
- WorktreeCreate: ['worktreePath'],
95
- WorktreeRemove: [],
96
- }
97
-
98
- export function classicHook<H extends Hook>(name: string, event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
40
+ export function classicHook<H extends Hook>(event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
99
41
  const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
100
42
  const ours = await Promise.resolve()
101
- .then(async () => {
102
- const answer = await hook(e as ClassicHookInputs[H])
103
- return answer === undefined ? undefined : classicResult(event, answer)
104
- })
105
- .catch((error: unknown): ClassicFields | undefined => {
106
- if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: `${name}: the ${event} listener failed: ${messageOf(error)}` } }
43
+ .then(() => hook(e as ClassicHookInputs[H]))
44
+ .catch((error: unknown) => {
107
45
  report({ id: `hook:${event}`, message: `The ${event} listener failed`, error })
108
46
  return undefined
109
47
  })
110
48
  if (ours === undefined) return next(e)
111
- return mergeClassic((await next(e)) as ClassicFields, ours)
49
+ return mergeClassic((await next(e)) as HookFields, ours)
112
50
  }
113
51
  return routed as unknown as RoutedHook<RoutedEvent>
114
52
  }
@@ -134,9 +72,11 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
134
72
  }
135
73
  }
136
74
 
137
- export async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
75
+ export type HomeFolders = { readonly home: string | undefined; readonly configRoot: string | undefined }
76
+
77
+ export async function callFiles(name: string, claude: Claude, folders: Promise<HomeFolders>, use: CallBase, cwd: string): Promise<CallFiles> {
138
78
  try {
139
- const [home, configRoot] = await Promise.all([claude.env.home(), claude.env.configHome()])
79
+ const { home, configRoot } = await folders
140
80
  if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
141
81
  const { shell, reads, writes } = callEffects(use, { cwd, home, ...(configRoot === undefined ? {} : { configRoot }), fs: claude.fs })
142
82
  const known = (accesses: FileAccess[]) => accesses.map(({ path }) => path).filter((path) => shell === undefined || !dynamicPattern.test(path))
@@ -152,21 +92,8 @@ export async function callFiles(name: string, claude: Claude, use: CallBase, cwd
152
92
  }
153
93
  }
154
94
 
155
- function classicResult(event: Hook, answer: HookAnswer): ClassicFields {
156
- const result: ClassicFields = {}
157
- if (answer.decision === 'block') result['block'] = answer.reason ?? ''
158
- const specific = answer.hookSpecificOutput ?? {}
159
- for (const [field, value] of Object.entries(specific)) {
160
- if (field === 'hookEventName' || (value === false && flags.includes(field))) continue
161
- if (!readFields[event].includes(field)) throw new Error(`it answered ${field}, which ${event} does not read.`)
162
- result[field] = field === 'additionalContext' ? [value] : value
163
- }
164
- return result
165
- }
166
-
167
-
168
- function mergeClassic(below: ClassicFields, ours: ClassicFields): ClassicFields {
169
- const merged: ClassicFields = { ...below, ...ours }
95
+ function mergeClassic(below: HookFields, ours: HookFields): HookFields {
96
+ const merged: Record<string, unknown> = { ...below, ...ours }
170
97
  const context = [...((below['additionalContext'] as string[] | undefined) ?? []), ...((ours['additionalContext'] as string[] | undefined) ?? [])]
171
98
  if (context.length > 0) merged['additionalContext'] = context
172
99
  return merged
@@ -4,7 +4,7 @@ import type { Allowed, FileChange, Disposable, Imports, Mod, ModDefinition, Pane
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'
7
- import { firstLine, formatExit, listed, messageOf } from '../utils/text.js'
7
+ import { firstLine, formatExit, messageOf } from '../utils/text.js'
8
8
  import type { Claude } from './claude.js'
9
9
  import { beforeDeadline } from './deadline.js'
10
10
  import { announcedOf, announcementMs, announcementOf } from './announce.js'
@@ -12,7 +12,8 @@ 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, permissionEvents, userSkillHook, type Hook, type HookListener, type RoutedEvent } from './hooks.js'
15
+ import { classicHook, 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) }
@@ -939,7 +957,7 @@ async function createMod<State extends object, Declared extends Options>(definit
939
957
  return served ?? answerCall(definition.name, api, e)
940
958
  })
941
959
  const agents = toolCalls(claude, router)
942
- keepEngine(mod, { claude: checked, router, toolCalls: agents, report })
960
+ keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined })
943
961
  const names = new Set<string>()
944
962
  const reserveName = (type: string, name: string, taken: string): Disposable => {
945
963
  const key = `${type}:${name}`
@@ -954,10 +972,6 @@ async function createMod<State extends object, Declared extends Options>(definit
954
972
  await failsAs('the details of its session did not load', () => followCurrent(undefined))
955
973
  await failsAs('its setup function threw', () => definition.setup(mod))
956
974
  isSetUp = true
957
- const permissionHooks = permissionEvents.filter((event) => router.has(event))
958
- if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
959
- throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
960
- }
961
975
  await failsAs('its open panes did not load', () => area.restorePanes())
962
976
  return { mod, added, imports, raise: client.raise }
963
977
  }
@@ -965,7 +979,7 @@ async function createMod<State extends object, Declared extends Options>(definit
965
979
  function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
966
980
  const bound = Object.entries(keys)
967
981
  if (bound.length === 0) return claude
968
- const register: Claude['command']['register'] = (command) => {
982
+ const addCommand: Claude['command']['register'] = (command) => {
969
983
  for (const [key, commandName] of bound) {
970
984
  if (commandName === command.name && command.immediate !== true) {
971
985
  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 +987,7 @@ function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string
973
987
  }
974
988
  return claude.command.register(command)
975
989
  }
976
- return { ...claude, command: { ...claude.command, register } }
990
+ return { ...claude, command: { ...claude.command, register: addCommand } }
977
991
  }
978
992
 
979
993
  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
+ }
@@ -8,7 +8,6 @@ export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: Route
8
8
 
9
9
  export type Router = {
10
10
  add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): Disposable
11
- has(event: RoutedEvent): boolean
12
11
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
13
12
  }
14
13
 
@@ -29,7 +28,6 @@ export function createRouter(): Router {
29
28
  },
30
29
  }
31
30
  },
32
- has: (event) => hooks.has(event),
33
31
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
34
32
  const chain = hooks.get(event)
35
33
  const call = async (index: number, current: unknown): Promise<unknown> => {