@cmodjs/core 1.6.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/docs/commands.md +4 -4
  2. package/docs/index.md +12 -11
  3. package/docs/jobs.md +12 -16
  4. package/docs/mod.md +111 -36
  5. package/docs/permissions.md +14 -18
  6. package/docs/session.md +171 -82
  7. package/docs/state.md +5 -5
  8. package/docs/testing.md +57 -41
  9. package/docs/ui.md +9 -9
  10. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  11. package/jobs/permissions/decide-permission.js +1 -1
  12. package/jobs/prompt.d.ts +1 -3
  13. package/jobs/prompt.d.ts.map +1 -1
  14. package/jobs/prompt.js +2 -21
  15. package/jobs/slash-command.d.ts +1 -1
  16. package/jobs/slash-command.d.ts.map +1 -1
  17. package/jobs/slash-command.js +1 -1
  18. package/jobs/status-line.d.ts +0 -1
  19. package/jobs/status-line.d.ts.map +1 -1
  20. package/jobs/status-line.js +3 -4
  21. package/mod.d.ts +53 -105
  22. package/mod.d.ts.map +1 -1
  23. package/mod.js +18 -80
  24. package/package.json +1 -1
  25. package/records.d.ts +1 -1
  26. package/records.js +1 -1
  27. package/register.d.ts.map +1 -1
  28. package/register.js +3 -1
  29. package/runtime/before.d.ts +5 -1
  30. package/runtime/before.d.ts.map +1 -1
  31. package/runtime/before.js +81 -39
  32. package/runtime/claude.d.ts +6 -1
  33. package/runtime/claude.d.ts.map +1 -1
  34. package/runtime/conversations.d.ts.map +1 -1
  35. package/runtime/conversations.js +45 -26
  36. package/runtime/deadline.d.ts +17 -0
  37. package/runtime/deadline.d.ts.map +1 -1
  38. package/runtime/deadline.js +34 -0
  39. package/runtime/dependencies.d.ts +2 -0
  40. package/runtime/dependencies.d.ts.map +1 -1
  41. package/runtime/dependencies.js +12 -0
  42. package/runtime/grants.d.ts.map +1 -1
  43. package/runtime/grants.js +13 -8
  44. package/runtime/hooks.d.ts +28 -11
  45. package/runtime/hooks.d.ts.map +1 -1
  46. package/runtime/hooks.js +20 -123
  47. package/runtime/lifecycle.d.ts +2 -1
  48. package/runtime/lifecycle.d.ts.map +1 -1
  49. package/runtime/lifecycle.js +66 -49
  50. package/runtime/sessions.d.ts +31 -5
  51. package/runtime/sessions.d.ts.map +1 -1
  52. package/runtime/sessions.js +342 -81
  53. package/runtime/tool-calls.d.ts +0 -1
  54. package/runtime/tool-calls.d.ts.map +1 -1
  55. package/runtime/tool-calls.js +0 -19
  56. package/session/context/turn/message/instruction-file.d.ts +9 -0
  57. package/session/context/turn/message/instruction-file.d.ts.map +1 -0
  58. package/session/context/turn/message/instruction-file.js +4 -0
  59. package/session/context/turn/message/message.d.ts +5 -4
  60. package/session/context/turn/message/message.d.ts.map +1 -1
  61. package/session/context/turn/message/message.js +7 -6
  62. package/session/context/turn/message/sender.d.ts +2 -1
  63. package/session/context/turn/message/sender.d.ts.map +1 -1
  64. package/session/context/turn/message/sender.js +4 -1
  65. package/session/context/turn/message/tool-call.d.ts +5 -0
  66. package/session/context/turn/message/tool-call.d.ts.map +1 -1
  67. package/session/context/turn/message/tool-result.d.ts +6 -1
  68. package/session/context/turn/message/tool-result.d.ts.map +1 -1
  69. package/session/context/turn/turn.d.ts +7 -0
  70. package/session/context/turn/turn.d.ts.map +1 -1
  71. package/session/conversation.d.ts +10 -3
  72. package/session/conversation.d.ts.map +1 -1
  73. package/session/conversation.js +14 -5
  74. package/session/goal/goal.d.ts +1 -1
  75. package/session/goal/goal.d.ts.map +1 -1
  76. package/session/goal/task.d.ts +2 -2
  77. package/session/goal/task.d.ts.map +1 -1
  78. package/session/session.d.ts +361 -26
  79. package/session/session.d.ts.map +1 -1
  80. package/session/session.js +40 -0
  81. package/src/jobs/permissions/decide-permission.ts +2 -1
  82. package/src/jobs/prompt.ts +3 -20
  83. package/src/jobs/slash-command.ts +2 -2
  84. package/src/jobs/status-line.ts +4 -7
  85. package/src/mod.ts +52 -147
  86. package/src/records.ts +1 -1
  87. package/src/register.ts +3 -1
  88. package/src/runtime/before.ts +90 -37
  89. package/src/runtime/claude.ts +5 -1
  90. package/src/runtime/conversations.ts +41 -25
  91. package/src/runtime/deadline.ts +38 -0
  92. package/src/runtime/dependencies.ts +12 -0
  93. package/src/runtime/grants.ts +11 -7
  94. package/src/runtime/hooks.ts +48 -116
  95. package/src/runtime/lifecycle.ts +65 -50
  96. package/src/runtime/sessions.ts +366 -90
  97. package/src/runtime/tool-calls.ts +0 -19
  98. package/src/session/context/turn/message/instruction-file.ts +13 -0
  99. package/src/session/context/turn/message/message.ts +11 -9
  100. package/src/session/context/turn/message/sender.ts +5 -2
  101. package/src/session/context/turn/message/tool-call.ts +3 -0
  102. package/src/session/context/turn/message/tool-result.ts +6 -1
  103. package/src/session/context/turn/turn.ts +4 -0
  104. package/src/session/conversation.ts +21 -8
  105. package/src/session/goal/goal.ts +1 -1
  106. package/src/session/goal/task.ts +2 -2
  107. package/src/session/session.ts +227 -24
  108. package/src/testing/claude-code.ts +23 -0
  109. package/src/testing/fake-claude.ts +5 -1
  110. package/src/testing/fake-sessions.ts +38 -24
  111. package/src/testing.ts +84 -16
  112. package/testing/claude-code.d.ts +12 -0
  113. package/testing/claude-code.d.ts.map +1 -0
  114. package/testing/claude-code.js +10 -0
  115. package/testing/fake-claude.d.ts +4 -0
  116. package/testing/fake-claude.d.ts.map +1 -1
  117. package/testing/fake-claude.js +4 -1
  118. package/testing/fake-sessions.d.ts +8 -6
  119. package/testing/fake-sessions.d.ts.map +1 -1
  120. package/testing/fake-sessions.js +35 -20
  121. package/testing.d.ts +38 -9
  122. package/testing.d.ts.map +1 -1
  123. package/testing.js +54 -5
  124. package/docs/hooks.md +0 -259
  125. package/session/context/turn/message/document.d.ts +0 -7
  126. package/session/context/turn/message/document.d.ts.map +0 -1
  127. package/session/context/turn/message/document.js +0 -1
  128. package/src/session/context/turn/message/document.ts +0 -8
@@ -3,8 +3,8 @@ import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
3
3
  import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
4
4
  import { Note } from '../session/context/turn/message/note.js'
5
5
  import { Person } from '../session/context/turn/message/sender.js'
6
- import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
7
- import { Session, UsageChange, type BeforeEvent, type Client, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
6
+ import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
7
+ import { Session, UsageChange, type BeforeEvent, type Surface, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
8
8
  import { GoalChange } from '../session/goal/goal.js'
9
9
  import { receivedMatcher } from '../utils/matcher.js'
10
10
  import { ifMissing } from '../utils/paths.js'
@@ -14,7 +14,7 @@ import type { Claude } from './claude.js'
14
14
  import { cmodPluginName } from './dependencies.js'
15
15
  import { readLines, runCmod } from './programs.js'
16
16
  import type { Router } from './router.js'
17
- import { actPermission, beforePermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, usageOf, type Act, type Subscription } from './sessions.js'
17
+ import { actPermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
18
18
 
19
19
  type Request = Readonly<Record<string, unknown>>
20
20
 
@@ -64,6 +64,21 @@ export function serveConversations({ claude, router, store, project, report }: {
64
64
  const liveIds = new Set<string>()
65
65
  const waitingRows = new Map<string, Row[]>()
66
66
  let follower: { readonly sessions: string; stop(): void } | undefined
67
+ const engineTurns = new Map<string, string>()
68
+ router.add('turn.start', async (e, next) => {
69
+ const agent = (e as typeof e & { readonly agentId?: string }).agentId ?? ''
70
+ engineTurns.set(agent, e.turnId)
71
+ return next(e)
72
+ })
73
+ const endTurn = (ending: TurnEnding, task?: string) => <Below extends { readonly block?: string }>(below: Below): Below => {
74
+ if (below.block !== undefined) return below
75
+ engineTurns.delete(task ?? '')
76
+ dispatch(session, conversation.endTurn(new Date().toISOString(), ending, task))
77
+ return below
78
+ }
79
+ router.add('classic.Stop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) })(await next(e)))
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
+ 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)))
67
82
 
68
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
69
84
 
@@ -94,6 +109,7 @@ export function serveConversations({ claude, router, store, project, report }: {
94
109
  }
95
110
 
96
111
  const restartFollower = async () => {
112
+ if (session === '') return
97
113
  const wantsEvery = [...heard.values()].some((sessions) => (sessions.get(everySession) ?? []).length > 0)
98
114
  const named = [...heard.values()].flatMap((sessions) => [...sessions].filter(([id, list]) => id !== session && id !== everySession && list.length > 0).map(([id]) => id))
99
115
  const others = [...new Set(named)].sort()
@@ -189,8 +205,8 @@ export function serveConversations({ claude, router, store, project, report }: {
189
205
  }
190
206
 
191
207
  const place = (): Place => {
192
- const { context, contextOpenedAt, turn, tasks, taskTurns } = conversation.state
193
- return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns }
208
+ const { context, contextOpenedAt, turn, tasks, taskTurns, openCalls } = conversation.state
209
+ return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns, ...(openCalls === undefined ? {} : { openCalls }) }
194
210
  }
195
211
 
196
212
  const deciders = (event: string): Watcher[] => {
@@ -199,7 +215,7 @@ export function serveConversations({ claude, router, store, project, report }: {
199
215
  }
200
216
 
201
217
  const askHere = async (mod: string, event: BeforeEvent, change: SessionChange, from = session, isInProject = true) => {
202
- for (const needed of ['conversation', beforePermission(event)]) if (!(await isGranted(mod, needed))) throw new Error(`${mod} needs the ${needed} permission for before(${event}). Turn it on in /mods ${mod}.`)
218
+ if (from !== session && !(await isGranted(mod, 'conversation'))) throw new Error(`${mod} needs the conversation permission to change what happens in another session. Turn it on in /mods ${mod}.`)
203
219
  return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
204
220
  }
205
221
 
@@ -360,9 +376,10 @@ export function serveConversations({ claude, router, store, project, report }: {
360
376
  const perform = async (caller: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
361
377
  await checkAct(caller, action, input)
362
378
  await starting
379
+ if (action === 'abort') return abortTurn(input, task)
363
380
  if (task !== undefined) return performInTask(action, input, task)
364
381
  if (action === 'usage') return usageOf(claude)
365
- if (action === 'clients') return [...(await claude.session.surfaces())] satisfies Client[]
382
+ if (action === 'surfaces') return [...(await claude.session.surfaces())] satisfies Surface[]
366
383
  if (action === 'send') {
367
384
  const { type, text, as } = input as SessionSend
368
385
  if (as === Session) throw new Error(`session ${session} cannot send as itself to itself.`)
@@ -371,26 +388,25 @@ export function serveConversations({ claude, router, store, project, report }: {
371
388
  if ('drop' in delivered && typeof delivered.drop === 'string') throw new Error(`Claude Code dropped the prompt: ${delivered.drop}`)
372
389
  return null
373
390
  }
374
- if (action === 'startTask') {
375
- const args = input as TaskStartArgs
376
- const started = await claude.agent.spawn({
377
- prompt: args.prompt,
378
- ...(args.name === undefined ? {} : { description: args.name, name: args.name }),
379
- ...(args.agentType === undefined ? {} : { subagentType: args.agentType }),
380
- ...(args.model === undefined ? {} : { model: args.model }),
381
- ...(args.cwd === undefined ? {} : { cwd: args.cwd }),
382
- })
383
- if (started.deny !== undefined) throw new Error(`Claude Code refused the task: ${started.deny}`)
384
- return { id: started.agentId ?? '', title: args.name ?? '', agentType: args.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() } satisfies TaskData
385
- }
391
+ if (action === 'startTask') return spawnTask(claude, input as TaskStartArgs)
386
392
  const { name, input: toolInput } = input as ToolCallArgs
387
- return resultOf(place(), await claude.tool.call({ ...toolInput, tool: name } as never))
393
+ return resultOf(place(), { name, input: toolInput, cwd: await claude.session.cwd() }, await claude.tool.call({ ...toolInput, tool: name } as never))
394
+ }
395
+
396
+ const abortTurn = async (input: unknown, task: string | undefined) => {
397
+ const turnId = engineTurns.get(task ?? '')
398
+ if (turnId === undefined) throw new Error(`no turn is running in ${task === undefined ? `session ${session}` : `task ${task}`}.`)
399
+ const asked = isObject(input) ? input['turn'] : undefined
400
+ const open = task === undefined ? conversation.state.turn : conversation.state.taskTurns[task]
401
+ if (open?.prompt.id !== asked) throw new Error(`turn ${String(asked)} has ended, so there is nothing to abort.`)
402
+ await claude.turn.abort({ turnId })
403
+ return null
388
404
  }
389
405
 
390
406
  const performInTask = async (action: Act, input: unknown, task: string): Promise<unknown> => {
391
407
  const running = conversation.state.tasks[task]
392
408
  if (action === 'usage') return json(['history', 'usage', session, '--task', task])
393
- if (action === 'clients') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
409
+ if (action === 'surfaces') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
394
410
  if (action !== 'send') throw new Error(`cmod: ${action} runs only on a session's own main loop.`)
395
411
  if (running === undefined || running.closedAt !== undefined) throw ended(task)
396
412
  const sent = await claude.session.send({ to: { agentId: task }, text: (input as SessionSend).text })
@@ -402,7 +418,7 @@ export function serveConversations({ claude, router, store, project, report }: {
402
418
  await checkAct(caller, action, input)
403
419
  const isLive = await isRunning(target)
404
420
  if (!isLive && action === 'usage') return json(['history', 'usage', target, ...(task === undefined ? [] : ['--task', task])])
405
- if (!isLive && action === 'clients') return []
421
+ if (!isLive && action === 'surfaces') return []
406
422
  if (!isLive) throw ended(task ?? target)
407
423
  if (action === 'send' && task === undefined && (input as SessionSend).as === Session) {
408
424
  const sent = await claude.session.send({ to: { sessionId: target }, text: (input as SessionSend).text })
@@ -452,8 +468,8 @@ export function serveConversations({ claude, router, store, project, report }: {
452
468
  },
453
469
  async [conversationMethods.search](request) {
454
470
  const before = isObject(request['before']) ? request['before'] : undefined
455
- const record = [...textArg('--session', request['session']), ...textArg('--task', request['task']), ...textArg('--turn', request['turn']), ...countArg('--context', request['context']), ...(request['isGoal'] === true ? ['--goal'] : [])]
456
- return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...record, ...(request['running'] === true ? ['--running'] : []), ...countArg('--limit', request['limit']), ...(before === undefined ? [] : ['--before', `${String(before['session'])}:${String(before['id'])}`])])
471
+ const record = [...textArg('--session', request['session']), ...textArg('--task', request['task']), ...textArg('--turn', request['turn']), ...countArg('--context', request['context']), ...(request['isAgent'] === true ? ['--agent'] : [])]
472
+ return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...record, ...(request['running'] === true ? ['--running'] : []), ...(before === undefined ? [] : ['--before', `${String(before['session'])}:${String(before['id'])}`])])
457
473
  },
458
474
  async [conversationMethods.messages](request) {
459
475
  const id = String(request['session'])
@@ -556,7 +572,7 @@ export function serveConversations({ claude, router, store, project, report }: {
556
572
  const method = methods[e.method]
557
573
  if (method === undefined) return undefined
558
574
  const input = isObject(e.input) ? e.input : {}
559
- if (e.method !== conversationMethods.act && !(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() }
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() }
560
576
  try {
561
577
  return { value: await method(input, e.caller) }
562
578
  } catch (error) {
@@ -6,6 +6,44 @@ export const longestMs = 600_000
6
6
 
7
7
  export const toolDeadline: Deadline = { ms: 30000, job: 'tool' }
8
8
 
9
+ export type ListenOptions = { readonly timeoutMs?: number; readonly onFailure?: 'pass' | 'refuse' }
10
+
11
+ export type ListenTime = { readonly deadline: number; readonly signal: AbortSignal }
12
+
13
+ export const listenerMs = 10_000
14
+
15
+ export class PassedTimeout extends Error {}
16
+
17
+ const timers = globalThis as unknown as { setTimeout(run: () => void, ms: number): unknown; clearTimeout(timer: unknown): void }
18
+
19
+ export function listenerOf(name: string, method: string, args: readonly unknown[]): { readonly matcher: unknown; readonly listener: (payload: never, time: ListenTime) => unknown; readonly options: ListenOptions } {
20
+ const hasMatcher = typeof args[1] === 'function'
21
+ const listener = (hasMatcher ? args[1] : args[0]) as (payload: never, time: ListenTime) => unknown
22
+ const options = ((hasMatcher ? args[2] : args[1]) ?? {}) as ListenOptions
23
+ if (typeof listener !== 'function') throw new Error(`${name}: ${method} takes a listener, or a matcher and then a listener.`)
24
+ const { timeoutMs, onFailure } = options as { readonly timeoutMs?: unknown; readonly onFailure?: unknown }
25
+ if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > longestMs)) throw new Error(`${name}: ${method}'s timeoutMs is ${String(timeoutMs)}. Give a whole number of milliseconds above 0 and at most ${longestMs}.`)
26
+ if (onFailure !== undefined && onFailure !== 'pass' && onFailure !== 'refuse') throw new Error(`${name}: ${method}'s onFailure is ${String(onFailure)}. Give 'pass' or 'refuse'.`)
27
+ return { matcher: hasMatcher ? args[0] : undefined, listener, options }
28
+ }
29
+
30
+ export async function withinTime<Value>(clock: Pick<Claude['clock'], 'now'>, method: string, { timeoutMs = listenerMs }: ListenOptions, run: (time: ListenTime) => Value | Promise<Value>): Promise<Value> {
31
+ const controller = new AbortController()
32
+ const deadline = (await clock.now()) + timeoutMs
33
+ let timer: unknown
34
+ const passed = new Promise<never>((_resolve, reject) => {
35
+ timer = timers.setTimeout(() => {
36
+ controller.abort()
37
+ reject(new PassedTimeout(`${method} passed its ${timeoutMs / 1000} s timeout`))
38
+ }, timeoutMs)
39
+ })
40
+ try {
41
+ return await Promise.race([Promise.resolve().then(() => run({ deadline, signal: controller.signal })), passed])
42
+ } finally {
43
+ timers.clearTimeout(timer)
44
+ }
45
+ }
46
+
9
47
  export function beforeDeadline<Value>(claude: Claude, { ms, job }: Deadline, call: string, task: Promise<Value>): Promise<Value> {
10
48
  return new Promise<Value>((resolve, reject) => {
11
49
  const passed = job === undefined ? `its ${ms / 1000} s deadline` : `the ${ms / 1000} s deadline of ${job}`
@@ -1,6 +1,7 @@
1
1
  import type { Args, CmodDependencies, EventResult, Frozen } from 'claude-code'
2
2
  import { messageOf } from '../utils/text.js'
3
3
  import type { Claude } from './claude.js'
4
+ import type { Router } from './router.js'
4
5
 
5
6
  export const cmodPluginName = 'cmod'
6
7
 
@@ -33,6 +34,17 @@ export function withBareRefusals(claude: Claude): Claude {
33
34
  return { ...claude, cmod: { call } }
34
35
  }
35
36
 
37
+ export function answeringOwnCalls(claude: Claude, router: Router): Claude {
38
+ const name = claude.plugin.name
39
+ const call: Claude['cmod']['call'] = async (input) => {
40
+ if (input.to !== name) return claude.cmod.call(input)
41
+ const answer = await router.dispatch('cmod.call', { ...input, caller: name } as Frozen<Args<'cmod.call'>>, async () => ({ deny: `${name} has no method ${input.method}.` }))
42
+ if (answer.deny !== undefined) throw new Error(answer.deny)
43
+ return answer.value
44
+ }
45
+ return { ...claude, cmod: { call } }
46
+ }
47
+
36
48
  export function notInstalled(name: string): string {
37
49
  return `${name} is not installed. Add it with /plugin.`
38
50
  }
@@ -90,10 +90,6 @@ export function checkingGrants(claude: Claude, grants: Grants): Claude {
90
90
  },
91
91
  session: {
92
92
  ...claude.session,
93
- messages: (async (...args: Parameters<Claude['session']['messages']>) => {
94
- await gate('session.messages', 'conversation')
95
- return claude.session.messages(...args)
96
- }) as Claude['session']['messages'],
97
93
  append: async (args: Parameters<Claude['session']['append']>[0]) => {
98
94
  await gate('send', 'prompt')
99
95
  return claude.session.append(args)
@@ -109,12 +105,22 @@ export function checkingGrants(claude: Claude, grants: Grants): Claude {
109
105
  return claude.prompt.submit(args)
110
106
  },
111
107
  },
108
+ command: {
109
+ ...claude.command,
110
+ run: async (args: Parameters<Claude['command']['run']>[0]) => {
111
+ await gate(`commands.run(${args.command})`, 'prompt')
112
+ return claude.command.run(args)
113
+ },
114
+ },
112
115
  cmod: {
113
116
  async call(args) {
114
117
  const named = args.to === cmodPluginName ? conversationCalls[args.method] : undefined
115
118
  const given = args.method === conversationMethods.act && isObject(args.input) ? String(args.input['action']) : undefined
116
119
  const action = given !== undefined && isAct(given) ? given : undefined
117
- if (named !== undefined) await gate(action ?? named, action === undefined ? 'conversation' : actPermission(action, isObject(args.input) ? args.input['input'] : undefined))
120
+ const isThisSession = isObject(args.input) && args.input['session'] === (await claude.session.id())
121
+ const isDropping = (args.method === conversationMethods.subscribe || args.method === conversationMethods.watchBefore) && isObject(args.input) && Array.isArray(args.input['subscriptions']) && args.input['subscriptions'].length === 0
122
+ if (named !== undefined && action !== undefined) await gate(action, actPermission(action, isObject(args.input) ? args.input['input'] : undefined))
123
+ else if (named !== undefined && !isThisSession && !isDropping) await gate(named, 'conversation')
118
124
  return claude.cmod.call(args)
119
125
  },
120
126
  },
@@ -211,10 +217,8 @@ type Fields = Record<string, unknown>
211
217
  const classicParts: Readonly<Record<string, string>> = {
212
218
  additionalContext: 'prompt',
213
219
  initialUserMessage: 'prompt',
214
- suppressOriginalPrompt: 'prompt',
215
220
  updatedToolOutput: 'prompt',
216
221
  updatedMCPToolOutput: 'prompt',
217
- updatedInput: 'tools',
218
222
  retry: 'approve',
219
223
  }
220
224
 
@@ -1,17 +1,38 @@
1
- import type { Args, ClassicHookInputs, EventResult, Frozen } from 'claude-code'
2
- import type { Hook, HookAnswer, HookInput, HookListener } from '../mod.js'
1
+ import type { ClassicHookEvent, ClassicHookInputs, PermissionRequestDecision } from 'claude-code'
3
2
  import { configFolders } from '../records.js'
4
3
  import type { Claude } from './claude.js'
5
4
  import type { RoutedHook } from './router.js'
6
5
  import type { Failure } from './toaster.js'
7
- import { reservedKeys, toolInputOf, type ToolCalls } from './tool-calls.js'
8
- import { callEffects, inputOf, type CallBase, type FileAccess } from '../utils/call-effects.js'
6
+ import { callEffects, type CallBase, type FileAccess } from '../utils/call-effects.js'
9
7
  import { dynamicPattern } from '../utils/parse-shell.js'
10
8
  import { isMissing } from '../utils/paths.js'
11
9
  import { messageOf } from '../utils/text.js'
12
10
 
11
+ export type Hook = Exclude<ClassicHookEvent, 'PreToolUse'>
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
+ }
31
+
32
+ export type HookListener<H extends Hook> = (input: ClassicHookInputs[H]) => HookAnswer | void | Promise<HookAnswer | void>
33
+
13
34
  export type RoutedEvent =
14
- | `classic.${Exclude<Hook, 'PreToolUse'>}`
35
+ | `classic.${Hook}`
15
36
  | 'tool.check'
16
37
  | 'tool.call'
17
38
  | 'prompt.submit'
@@ -29,30 +50,30 @@ export type RoutedEvent =
29
50
  | 'session.compact'
30
51
  | 'session.receive'
31
52
  | 'agent.spawn'
53
+ | 'turn.start'
32
54
 
33
55
  export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
34
56
 
35
57
  type ClassicFields = Record<string, unknown>
36
58
 
37
- type CallFiles = HookInput<'PreToolUse'>['files']
59
+ type CallFiles = { readonly read: string[]; readonly changed: string[] }
38
60
 
39
- const flags: readonly string[] = ['suppressOriginalPrompt', 'reloadSkills', 'retry']
61
+ const flags: readonly string[] = ['reloadSkills', 'retry']
40
62
 
41
63
  const readFields: Record<Hook, readonly string[]> = {
42
64
  Setup: ['additionalContext'],
43
65
  SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
44
66
  SessionEnd: [],
45
- UserPromptSubmit: ['additionalContext', 'sessionTitle', 'suppressOriginalPrompt'],
46
- UserPromptExpansion: ['additionalContext', 'suppressOriginalPrompt'],
67
+ UserPromptSubmit: ['additionalContext', 'sessionTitle'],
68
+ UserPromptExpansion: ['additionalContext'],
47
69
  InstructionsLoaded: [],
48
70
  ConfigChange: [],
49
71
  DirectoryAdded: [],
50
72
  PreModelSwitch: ['permissionDecision', 'permissionDecisionReason'],
51
73
  PostModelSwitch: ['additionalContext'],
52
- PreToolUse: ['additionalContext', 'permissionDecision', 'permissionDecisionReason', 'updatedInput'],
53
74
  PermissionRequest: ['decision'],
54
75
  PermissionDenied: ['retry'],
55
- PostToolUse: ['additionalContext', 'updatedToolOutput', 'updatedMCPToolOutput'],
76
+ PostToolUse: ['additionalContext'],
56
77
  PostToolUseFailure: ['additionalContext'],
57
78
  PostToolBatch: ['additionalContext'],
58
79
  MessageDisplay: ['displayContent'],
@@ -74,80 +95,24 @@ const readFields: Record<Hook, readonly string[]> = {
74
95
  WorktreeRemove: [],
75
96
  }
76
97
 
77
- export function classicHook<E extends Exclude<Hook, 'PreToolUse'>>(name: string, event: E, hook: HookListener<E>, claude: Claude, calls: ToolCalls, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
78
- const hookInputOf = async (e: unknown): Promise<unknown> => {
79
- if (event !== 'PostToolUse' && event !== 'PostToolUseFailure') return e
80
- const input = e as ClassicHookInputs['PostToolUse' | 'PostToolUseFailure']
81
- const cwd = calls.cwdOf(input.tool_use_id) ?? input.cwd
82
- return { ...input, files: await callFiles(name, claude, { tool: input.tool_name, input: inputOf(input.tool_input) }, cwd) }
83
- }
84
- const resultOf = async (e: unknown): Promise<ClassicFields | undefined> => {
85
- const answer = await hook((await hookInputOf(e)) as HookInput<E>)
86
- if (answer === undefined) return undefined
87
- if (answer.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
88
- return classicResult(event, answer)
89
- }
98
+ export function classicHook<H extends Hook>(name: string, event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
90
99
  const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
91
- const ours = await resultOf(e).catch((error: unknown): ClassicFields | undefined => {
92
- if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: `${name}: the ${event} hook failed: ${messageOf(error)}` } }
93
- report({ id: `hook:${event}`, message: `The ${event} hook failed`, error })
94
- return undefined
95
- })
100
+ 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)}` } }
107
+ report({ id: `hook:${event}`, message: `The ${event} listener failed`, error })
108
+ return undefined
109
+ })
96
110
  if (ours === undefined) return next(e)
97
111
  return mergeClassic((await next(e)) as ClassicFields, ours)
98
112
  }
99
113
  return routed as unknown as RoutedHook<RoutedEvent>
100
114
  }
101
115
 
102
- type HeldDecision = { readonly decision: 'allow' | 'ask'; readonly reason?: string }
103
-
104
- export type HeldDecisions = Map<string, HeldDecision>
105
-
106
- export function preToolUseHook(name: string, hook: HookListener<'PreToolUse'>, claude: Claude, calls: ToolCalls, held: HeldDecisions | undefined): RoutedHook<'tool.call'> {
107
- return async (e, next) => {
108
- let ours: ClassicFields
109
- try {
110
- const answer = await hook(await preToolUseInput(name, e, claude, calls.agentOf))
111
- if (answer?.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
112
- ours = answer === undefined ? {} : classicResult('PreToolUse', answer)
113
- } catch (error) {
114
- return { deny: `${name}: the PreToolUse hook failed: ${messageOf(error)}` }
115
- }
116
- if (typeof ours['deny'] === 'string') return { deny: ours['deny'] }
117
- const decided = heldDecisionOf(ours)
118
- if (decided !== undefined && held === undefined) {
119
- return { deny: `${name}: the PreToolUse hook answered permissionDecision "${decided.decision}", so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.` }
120
- }
121
- const updated = ours['updatedInput'] as Record<string, unknown> | undefined
122
- const reserved = Object.fromEntries(Object.entries(e).filter(([key]) => reservedKeys.includes(key)))
123
- const earlier = held?.get(e.tool_use_id)
124
- if (decided !== undefined) held?.set(e.tool_use_id, earlier !== undefined && strictness.indexOf(earlier.decision) >= strictness.indexOf(decided.decision) ? earlier : decided)
125
- let result: EventResult<'tool.call'>
126
- try {
127
- result = await next(updated === undefined ? e : ({ ...reserved, ...updated } as Frozen<Args<'tool.call'>>))
128
- } finally {
129
- if (decided !== undefined) held?.delete(e.tool_use_id)
130
- }
131
- const context = ours['additionalContext'] as string[] | undefined
132
- if (context === undefined || result.deny !== undefined) return result
133
- return { ...result, context: [...(result.context ?? []), ...context] }
134
- }
135
- }
136
-
137
- export function heldDecisionHook(held: HeldDecisions): RoutedHook<'tool.check'> {
138
- return async (e, next) => {
139
- const below = await next(e)
140
- const ours = e.tool_use_id === undefined ? undefined : held.get(e.tool_use_id)
141
- return ours === undefined || below.decision === 'deny' ? below : ours
142
- }
143
- }
144
-
145
- function heldDecisionOf(ours: ClassicFields): HeldDecision | undefined {
146
- if (ours['allow'] !== undefined) return { decision: 'allow' }
147
- if (typeof ours['ask'] !== 'string') return undefined
148
- return ours['ask'] === '' ? { decision: 'ask' } : { decision: 'ask', reason: ours['ask'] }
149
- }
150
-
151
116
  const frontmatter = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/
152
117
 
153
118
  const baseDirectoryLine = /^Base directory for this skill: [^\n]*\n\n/
@@ -169,24 +134,7 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
169
134
  }
170
135
  }
171
136
 
172
- async function preToolUseInput(name: string, envelope: Frozen<Args<'tool.call'>>, claude: Claude, agentOf: ToolCalls['agentOf']): Promise<HookInput<'PreToolUse'>> {
173
- const { tool, tool_use_id } = envelope
174
- const [session_id, cwd, { agentId, agentType }] = await Promise.all([claude.session.id(), claude.session.cwd(), agentOf(tool_use_id)])
175
- const tool_input = toolInputOf(envelope)
176
- return {
177
- session_id,
178
- cwd,
179
- hook_event_name: 'PreToolUse',
180
- tool_name: tool,
181
- tool_input,
182
- tool_use_id,
183
- ...(agentId === undefined ? {} : { agent_id: agentId }),
184
- ...(agentType === undefined ? {} : { agent_type: agentType }),
185
- files: await callFiles(name, claude, { tool, input: tool_input }, cwd),
186
- }
187
- }
188
-
189
- async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
137
+ export async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
190
138
  try {
191
139
  const [home, configRoot] = await Promise.all([claude.env.home(), claude.env.configHome()])
192
140
  if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
@@ -206,32 +154,16 @@ async function callFiles(name: string, claude: Claude, use: CallBase, cwd: strin
206
154
 
207
155
  function classicResult(event: Hook, answer: HookAnswer): ClassicFields {
208
156
  const result: ClassicFields = {}
209
- const unread = (field: string) => new Error(`it answered ${field}, which ${event} does not read. Remove it from the answer.`)
210
- if (event === 'PreToolUse') {
211
- if (answer.continue === false || answer.stopReason !== undefined) throw unread('continue or stopReason')
212
- if (answer.decision === 'block') result['deny'] = answer.reason ?? ''
213
- } else {
214
- if (answer.continue === false) result['preventContinuation'] = true
215
- if (answer.stopReason !== undefined) result['stopReason'] = answer.stopReason
216
- if (answer.decision === 'block') result['block'] = answer.reason ?? ''
217
- }
157
+ if (answer.decision === 'block') result['block'] = answer.reason ?? ''
218
158
  const specific = answer.hookSpecificOutput ?? {}
219
- if (specific.hookEventName !== undefined && specific.hookEventName !== event) {
220
- throw new Error(`it answered hookSpecificOutput.hookEventName "${specific.hookEventName}". Set it to "${event}" or leave it out.`)
221
- }
222
159
  for (const [field, value] of Object.entries(specific)) {
223
- if (field === 'hookEventName' || value === undefined || (value === false && flags.includes(field))) continue
224
- if (!readFields[event].includes(field)) throw unread(`hookSpecificOutput.${field}`)
225
- if (field === 'additionalContext') result['additionalContext'] = [value]
226
- else if (field === 'permissionDecision' && event === 'PreToolUse') {
227
- const reason = specific.permissionDecisionReason ?? ''
228
- result[value as string] = value === 'allow' ? true : reason
229
- } else if (field !== 'permissionDecisionReason' || event !== 'PreToolUse') result[field] = value
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
230
163
  }
231
164
  return result
232
165
  }
233
166
 
234
- export const strictness = ['allow', 'ask', 'deny'] as const
235
167
 
236
168
  function mergeClassic(below: ClassicFields, ours: ClassicFields): ClassicFields {
237
169
  const merged: ClassicFields = { ...below, ...ours }