@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
@@ -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,21 @@
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 } 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 HookFields = Readonly<Record<string, unknown>>
14
+
15
+ export type HookListener<H extends Hook> = (input: ClassicHookInputs[H]) => HookFields | undefined | Promise<HookFields | undefined>
16
+
13
17
  export type RoutedEvent =
14
- | `classic.${Exclude<Hook, 'PreToolUse'>}`
18
+ | `classic.${Hook}`
15
19
  | 'tool.check'
16
20
  | 'tool.call'
17
21
  | 'prompt.submit'
@@ -29,125 +33,26 @@ export type RoutedEvent =
29
33
  | 'session.compact'
30
34
  | 'session.receive'
31
35
  | 'agent.spawn'
36
+ | 'turn.start'
32
37
 
33
38
  export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
34
39
 
35
- type ClassicFields = Record<string, unknown>
36
-
37
- type CallFiles = HookInput<'PreToolUse'>['files']
38
-
39
- const flags: readonly string[] = ['suppressOriginalPrompt', 'reloadSkills', 'retry']
40
+ type CallFiles = { readonly read: string[]; readonly changed: string[] }
40
41
 
41
- const readFields: Record<Hook, readonly string[]> = {
42
- Setup: ['additionalContext'],
43
- SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
44
- SessionEnd: [],
45
- UserPromptSubmit: ['additionalContext', 'sessionTitle', 'suppressOriginalPrompt'],
46
- UserPromptExpansion: ['additionalContext', 'suppressOriginalPrompt'],
47
- InstructionsLoaded: [],
48
- ConfigChange: [],
49
- DirectoryAdded: [],
50
- PreModelSwitch: ['permissionDecision', 'permissionDecisionReason'],
51
- PostModelSwitch: ['additionalContext'],
52
- PreToolUse: ['additionalContext', 'permissionDecision', 'permissionDecisionReason', 'updatedInput'],
53
- PermissionRequest: ['decision'],
54
- PermissionDenied: ['retry'],
55
- PostToolUse: ['additionalContext', 'updatedToolOutput', 'updatedMCPToolOutput'],
56
- PostToolUseFailure: ['additionalContext'],
57
- PostToolBatch: ['additionalContext'],
58
- MessageDisplay: ['displayContent'],
59
- SubagentStart: ['additionalContext'],
60
- SubagentStop: ['additionalContext'],
61
- TaskCreated: [],
62
- TaskCompleted: [],
63
- TeammateIdle: [],
64
- Elicitation: [],
65
- ElicitationResult: [],
66
- Notification: [],
67
- PreCompact: [],
68
- PostCompact: [],
69
- Stop: ['additionalContext'],
70
- StopFailure: [],
71
- CwdChanged: [],
72
- FileChanged: [],
73
- WorktreeCreate: ['worktreePath'],
74
- WorktreeRemove: [],
75
- }
76
-
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
- }
42
+ export function classicHook<H extends Hook>(event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
90
43
  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
- })
44
+ const ours = await Promise.resolve()
45
+ .then(() => hook(e as ClassicHookInputs[H]))
46
+ .catch((error: unknown) => {
47
+ report({ id: `hook:${event}`, message: `The ${event} listener failed`, error })
48
+ return undefined
49
+ })
96
50
  if (ours === undefined) return next(e)
97
- return mergeClassic((await next(e)) as ClassicFields, ours)
51
+ return mergeClassic((await next(e)) as HookFields, ours)
98
52
  }
99
53
  return routed as unknown as RoutedHook<RoutedEvent>
100
54
  }
101
55
 
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
56
  const frontmatter = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/
152
57
 
153
58
  const baseDirectoryLine = /^Base directory for this skill: [^\n]*\n\n/
@@ -169,26 +74,11 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
169
74
  }
170
75
  }
171
76
 
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
- }
77
+ export type HomeFolders = { readonly home: string | undefined; readonly configRoot: string | undefined }
188
78
 
189
- async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
79
+ export async function callFiles(name: string, claude: Claude, folders: Promise<HomeFolders>, use: CallBase, cwd: string): Promise<CallFiles> {
190
80
  try {
191
- const [home, configRoot] = await Promise.all([claude.env.home(), claude.env.configHome()])
81
+ const { home, configRoot } = await folders
192
82
  if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
193
83
  const { shell, reads, writes } = callEffects(use, { cwd, home, ...(configRoot === undefined ? {} : { configRoot }), fs: claude.fs })
194
84
  const known = (accesses: FileAccess[]) => accesses.map(({ path }) => path).filter((path) => shell === undefined || !dynamicPattern.test(path))
@@ -204,37 +94,8 @@ async function callFiles(name: string, claude: Claude, use: CallBase, cwd: strin
204
94
  }
205
95
  }
206
96
 
207
- function classicResult(event: Hook, answer: HookAnswer): ClassicFields {
208
- 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
- }
218
- 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
- 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
230
- }
231
- return result
232
- }
233
-
234
- export const strictness = ['allow', 'ask', 'deny'] as const
235
-
236
- function mergeClassic(below: ClassicFields, ours: ClassicFields): ClassicFields {
237
- const merged: ClassicFields = { ...below, ...ours }
97
+ function mergeClassic(below: HookFields, ours: HookFields): HookFields {
98
+ const merged: Record<string, unknown> = { ...below, ...ours }
238
99
  const context = [...((below['additionalContext'] as string[] | undefined) ?? []), ...((ours['additionalContext'] as string[] | undefined) ?? [])]
239
100
  if (context.length > 0) merged['additionalContext'] = context
240
101
  return merged