@cmodjs/core 2.0.1 → 3.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 (76) hide show
  1. package/docs/commands.md +2 -2
  2. package/docs/jobs.md +1 -1
  3. package/docs/mod.md +4 -3
  4. package/docs/permissions.md +1 -1
  5. package/docs/session.md +12 -2
  6. package/docs/testing.md +6 -3
  7. package/jobs/context.d.ts.map +1 -1
  8. package/jobs/context.js +2 -1
  9. package/jobs/permissions.d.ts.map +1 -1
  10. package/jobs/permissions.js +5 -2
  11. package/jobs/prompt.d.ts.map +1 -1
  12. package/jobs/prompt.js +2 -1
  13. package/jobs/slash-command.d.ts.map +1 -1
  14. package/jobs/slash-command.js +4 -3
  15. package/package.json +1 -1
  16. package/records.d.ts +1 -1
  17. package/records.js +1 -1
  18. package/register.d.ts.map +1 -1
  19. package/register.js +58 -51
  20. package/runtime/before.d.ts +3 -2
  21. package/runtime/before.d.ts.map +1 -1
  22. package/runtime/before.js +10 -7
  23. package/runtime/conversations.d.ts.map +1 -1
  24. package/runtime/conversations.js +46 -19
  25. package/runtime/engine.d.ts +1 -0
  26. package/runtime/engine.d.ts.map +1 -1
  27. package/runtime/hooks.d.ts +2 -2
  28. package/runtime/hooks.d.ts.map +1 -1
  29. package/runtime/hooks.js +7 -1
  30. package/runtime/lifecycle.d.ts +2 -1
  31. package/runtime/lifecycle.d.ts.map +1 -1
  32. package/runtime/lifecycle.js +33 -15
  33. package/runtime/permission-check.d.ts +3 -0
  34. package/runtime/permission-check.d.ts.map +1 -1
  35. package/runtime/permission-check.js +19 -6
  36. package/runtime/router.d.ts +3 -2
  37. package/runtime/router.d.ts.map +1 -1
  38. package/runtime/router.js +19 -12
  39. package/runtime/sessions.d.ts +14 -4
  40. package/runtime/sessions.d.ts.map +1 -1
  41. package/runtime/sessions.js +72 -28
  42. package/runtime/toaster.d.ts.map +1 -1
  43. package/runtime/toaster.js +3 -0
  44. package/session/context/turn/message/message.d.ts.map +1 -1
  45. package/session/context/turn/message/message.js +30 -2
  46. package/session/context/turn/message/note.d.ts +7 -0
  47. package/session/context/turn/message/note.d.ts.map +1 -1
  48. package/session/context/turn/message/note.js +12 -0
  49. package/session/session.d.ts +3 -1
  50. package/session/session.d.ts.map +1 -1
  51. package/src/jobs/context.ts +2 -1
  52. package/src/jobs/permissions.ts +11 -5
  53. package/src/jobs/prompt.ts +2 -1
  54. package/src/jobs/slash-command.ts +4 -3
  55. package/src/records.ts +1 -1
  56. package/src/register.ts +63 -52
  57. package/src/runtime/before.ts +13 -9
  58. package/src/runtime/conversations.ts +54 -27
  59. package/src/runtime/engine.ts +1 -0
  60. package/src/runtime/hooks.ts +9 -3
  61. package/src/runtime/lifecycle.ts +39 -19
  62. package/src/runtime/permission-check.ts +44 -22
  63. package/src/runtime/router.ts +23 -15
  64. package/src/runtime/sessions.ts +96 -38
  65. package/src/runtime/toaster.ts +2 -0
  66. package/src/session/context/turn/message/message.ts +27 -2
  67. package/src/session/context/turn/message/note.ts +16 -0
  68. package/src/session/session.ts +1 -2
  69. package/src/testing/fake-claude.ts +4 -3
  70. package/src/testing.ts +19 -12
  71. package/testing/fake-claude.d.ts +1 -0
  72. package/testing/fake-claude.d.ts.map +1 -1
  73. package/testing/fake-claude.js +3 -2
  74. package/testing.d.ts +7 -2
  75. package/testing.d.ts.map +1 -1
  76. package/testing.js +15 -9
package/src/register.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Args, EngineInterface, EventResult, Frozen, Next, On, PluginOptions, ToolCallArgs } from 'claude-code'
1
+ import type { Args, Caught, EngineInterface, EventResult, Frozen, Next, On, PluginOptions, ToolCallArgs } from 'claude-code'
2
2
  import type { ModDefinition } from './mod.js'
3
3
  import type { Options } from './options.js'
4
4
  import type { Claude } from './runtime/claude.js'
@@ -94,9 +94,20 @@ function routeToMod<N extends RoutedEvent>(_$: EngineInterface, eventInput: Froz
94
94
  return lifecycle.route(passOn.event, eventInput, (passed: unknown) => passOn(passed as Args<N>) as Promise<EventResult<N>>)
95
95
  }
96
96
 
97
+ function routeFailure<N extends RoutedEvent>(_$: EngineInterface, eventInput: Frozen<Args<N>>, passOn: Next<N> & Caught): Promise<EventResult<N>> {
98
+ return lifecycle.caught(passOn.event, eventInput, (passed: unknown) => passOn(passed as Args<N>) as Promise<EventResult<N>>, passOn.error)
99
+ }
100
+
101
+ const withCaller = (eventInput: Frozen<Args<'cmod.call'>>, passOn: Next<'cmod.call'>) => ({ ...eventInput, caller: passOn.origin.plugin }) as Frozen<Args<'cmod.call'>>
102
+
103
+ const withoutCaller = (passOn: Next<'cmod.call'>) => (passed: Frozen<Args<'cmod.call'>>) => passOn(Object.fromEntries(Object.entries(passed).filter(([key]) => key !== 'caller')) as Args<'cmod.call'>)
104
+
97
105
  function routeCallToMod(_$: EngineInterface, eventInput: Frozen<Args<'cmod.call'>>, passOn: Next<'cmod.call'>): Promise<EventResult<'cmod.call'>> {
98
- const called = { ...eventInput, caller: passOn.origin.plugin } as Frozen<Args<'cmod.call'>>
99
- return lifecycle.route('cmod.call', called, (passed) => passOn(Object.fromEntries(Object.entries(passed).filter(([key]) => key !== 'caller')) as Args<'cmod.call'>))
106
+ return lifecycle.route('cmod.call', withCaller(eventInput, passOn), withoutCaller(passOn))
107
+ }
108
+
109
+ function routeCallFailure(_$: EngineInterface, eventInput: Frozen<Args<'cmod.call'>>, passOn: Next<'cmod.call'> & Caught): Promise<EventResult<'cmod.call'>> {
110
+ return lifecycle.caught('cmod.call', withCaller(eventInput, passOn), withoutCaller(passOn), passOn.error)
100
111
  }
101
112
 
102
113
  export function registerMod<State extends object, Declared extends Options>(addHook: On, definition: ModDefinition<State, string, Declared>, options: PluginOptions): void {
@@ -104,58 +115,58 @@ export function registerMod<State extends object, Declared extends Options>(addH
104
115
  registered = definition as ModDefinition<object>
105
116
  lifecycle = createLifecycle(definition, () => permissionCheck, options)
106
117
  addHook('session.start', startMod)
107
- addHook('classic.Setup', routeToMod)
108
- addHook('classic.SessionStart', routeToMod)
109
- addHook('classic.SessionEnd', routeToMod)
110
- addHook('classic.UserPromptSubmit', routeToMod)
111
- addHook('classic.UserPromptExpansion', routeToMod)
112
- addHook('classic.InstructionsLoaded', routeToMod)
113
- addHook('classic.ConfigChange', routeToMod)
114
- addHook('classic.DirectoryAdded', routeToMod)
115
- addHook('classic.PreModelSwitch', routeToMod)
116
- addHook('classic.PostModelSwitch', routeToMod)
117
- addHook('classic.PermissionDenied', routeToMod)
118
- addHook('classic.PostToolUse', routeToMod)
119
- addHook('classic.PostToolUseFailure', routeToMod)
120
- addHook('classic.PostToolBatch', routeToMod)
121
- addHook('classic.MessageDisplay', routeToMod)
122
- addHook('classic.SubagentStart', routeToMod)
123
- addHook('classic.SubagentStop', routeToMod)
124
- addHook('classic.TaskCreated', routeToMod)
125
- addHook('classic.TaskCompleted', routeToMod)
126
- addHook('classic.TeammateIdle', routeToMod)
127
- addHook('classic.Elicitation', routeToMod)
128
- addHook('classic.ElicitationResult', routeToMod)
129
- addHook('classic.Notification', routeToMod)
130
- addHook('classic.PreCompact', routeToMod)
131
- addHook('classic.PostCompact', routeToMod)
132
- addHook('classic.Stop', routeToMod)
133
- addHook('classic.StopFailure', routeToMod)
134
- addHook('classic.FileChanged', routeToMod)
135
- addHook('classic.WorktreeCreate', routeToMod)
136
- addHook('classic.WorktreeRemove', routeToMod)
137
- addHook('tool.call', routeToMod)
138
- addHook('prompt.submit', routeToMod)
139
- addHook('prompt.context', routeToMod)
140
- addHook('command.run', routeToMod)
141
- addHook('session.measure', routeToMod)
142
- addHook('skill.prompt', routeToMod)
143
- addHook('ui.render', routeToMod)
144
- addHook('ui.press', routeToMod)
145
- addHook('ui.scroll', routeToMod)
146
- addHook('ui.close', routeToMod)
147
- addHook('cmod.call', routeCallToMod)
118
+ addHook('classic.Setup', routeToMod).catch(routeFailure)
119
+ addHook('classic.SessionStart', routeToMod).catch(routeFailure)
120
+ addHook('classic.SessionEnd', routeToMod).catch(routeFailure)
121
+ addHook('classic.UserPromptSubmit', routeToMod).catch(routeFailure)
122
+ addHook('classic.UserPromptExpansion', routeToMod).catch(routeFailure)
123
+ addHook('classic.InstructionsLoaded', routeToMod).catch(routeFailure)
124
+ addHook('classic.ConfigChange', routeToMod).catch(routeFailure)
125
+ addHook('classic.DirectoryAdded', routeToMod).catch(routeFailure)
126
+ addHook('classic.PreModelSwitch', routeToMod).catch(routeFailure)
127
+ addHook('classic.PostModelSwitch', routeToMod).catch(routeFailure)
128
+ addHook('classic.PermissionDenied', routeToMod).catch(routeFailure)
129
+ addHook('classic.PostToolUse', routeToMod).catch(routeFailure)
130
+ addHook('classic.PostToolUseFailure', routeToMod).catch(routeFailure)
131
+ addHook('classic.PostToolBatch', routeToMod).catch(routeFailure)
132
+ addHook('classic.MessageDisplay', routeToMod).catch(routeFailure)
133
+ addHook('classic.SubagentStart', routeToMod).catch(routeFailure)
134
+ addHook('classic.SubagentStop', routeToMod).catch(routeFailure)
135
+ addHook('classic.TaskCreated', routeToMod).catch(routeFailure)
136
+ addHook('classic.TaskCompleted', routeToMod).catch(routeFailure)
137
+ addHook('classic.TeammateIdle', routeToMod).catch(routeFailure)
138
+ addHook('classic.Elicitation', routeToMod).catch(routeFailure)
139
+ addHook('classic.ElicitationResult', routeToMod).catch(routeFailure)
140
+ addHook('classic.Notification', routeToMod).catch(routeFailure)
141
+ addHook('classic.PreCompact', routeToMod).catch(routeFailure)
142
+ addHook('classic.PostCompact', routeToMod).catch(routeFailure)
143
+ addHook('classic.Stop', routeToMod).catch(routeFailure)
144
+ addHook('classic.StopFailure', routeToMod).catch(routeFailure)
145
+ addHook('classic.FileChanged', routeToMod).catch(routeFailure)
146
+ addHook('classic.WorktreeCreate', routeToMod).catch(routeFailure)
147
+ addHook('classic.WorktreeRemove', routeToMod).catch(routeFailure)
148
+ addHook('tool.call', routeToMod).catch(routeFailure)
149
+ addHook('prompt.submit', routeToMod).catch(routeFailure)
150
+ addHook('prompt.context', routeToMod).catch(routeFailure)
151
+ addHook('command.run', routeToMod).catch(routeFailure)
152
+ addHook('session.measure', routeToMod).catch(routeFailure)
153
+ addHook('skill.prompt', routeToMod).catch(routeFailure)
154
+ addHook('ui.render', routeToMod).catch(routeFailure)
155
+ addHook('ui.press', routeToMod).catch(routeFailure)
156
+ addHook('ui.scroll', routeToMod).catch(routeFailure)
157
+ addHook('ui.close', routeToMod).catch(routeFailure)
158
+ addHook('cmod.call', routeCallToMod).catch(routeCallFailure)
159
+ addHook('session.compact', routeToMod).catch(routeFailure)
148
160
  if (definition.name !== cmodPluginName) return
149
- addHook('session.append', routeToMod)
150
- addHook('session.compact', routeToMod)
151
- addHook('session.receive', routeToMod)
152
- addHook('agent.spawn', routeToMod)
153
- addHook('prompt.attachment', routeToMod)
154
- addHook('turn.start', routeToMod)
161
+ addHook('session.append', routeToMod).catch(routeFailure)
162
+ addHook('session.receive', routeToMod).catch(routeFailure)
163
+ addHook('agent.spawn', routeToMod).catch(routeFailure)
164
+ addHook('prompt.attachment', routeToMod).catch(routeFailure)
165
+ addHook('turn.start', routeToMod).catch(routeFailure)
155
166
  }
156
167
 
157
168
  export function registerPermissionCheck(addHook: On): void {
158
169
  permissionCheck = permissionRoutes
159
- addHook('tool.check', routeToMod)
160
- addHook('classic.PermissionRequest', routeToMod)
170
+ addHook('tool.check', routeToMod).catch(routeFailure)
171
+ addHook('classic.PermissionRequest', routeToMod).catch(routeFailure)
161
172
  }
@@ -3,7 +3,7 @@ import type { Disposable } from '../mod.js'
3
3
  import { ContextEnd } from '../session/context/context.js'
4
4
  import { InstructionFile, isRulePath } from '../session/context/turn/message/instruction-file.js'
5
5
  import { instructionPathOf, Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
6
- import { Note } from '../session/context/turn/message/note.js'
6
+ import { Note, untagged } from '../session/context/turn/message/note.js'
7
7
  import { Notice } from '../session/context/turn/message/notice.js'
8
8
  import { Prompt } from '../session/context/turn/message/prompt.js'
9
9
  import { Reply } from '../session/context/turn/message/reply.js'
@@ -12,7 +12,7 @@ import { Skill } from '../session/context/turn/message/skill.js'
12
12
  import { ToolCall } from '../session/context/turn/message/tool-call.js'
13
13
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
14
14
  import { TurnEnd } from '../session/context/turn/turn.js'
15
- import type { ConversationState, SessionChange, TaskData } from '../session/conversation.js'
15
+ import type { ContextData, ConversationState, RunningTask, SessionChange, TaskData } from '../session/conversation.js'
16
16
  import { Task, TaskStart } from '../session/goal/task.js'
17
17
  import { sessionEvents, type BeforeEvent, type SessionEvent } from '../session/session.js'
18
18
  import type { Claude } from './claude.js'
@@ -37,7 +37,7 @@ export type Answers = {
37
37
  readonly owner: { readonly model: string }
38
38
  }
39
39
 
40
- export type BeforeHooks = { readonly place: () => Promise<Place>; readonly wants: (event: BeforeEvent) => Promise<boolean>; readonly decide: Decide }
40
+ export type BeforeHooks = { readonly place: () => Promise<Place>; readonly wants: (event: BeforeEvent) => Promise<boolean>; readonly fold: () => Decide }
41
41
 
42
42
  const appended: readonly BeforeEvent[] = [Note, Reply, Notice]
43
43
 
@@ -56,9 +56,9 @@ export function relayOf(e: Frozen<Args<'session.receive'>>): string | undefined
56
56
  }
57
57
 
58
58
  export function installBefore({ claude, router, hooks }: { readonly claude: Claude; readonly router: Router; readonly hooks: BeforeHooks }): Disposable {
59
- const { place, wants, decide } = hooks
59
+ const { place, wants, fold } = hooks
60
60
  const now = () => new Date().toISOString()
61
- const asked = async (event: BeforeEvent, change: SessionChange) => (await decide(event, change)) ?? {}
61
+ const asked = async (event: BeforeEvent, change: SessionChange, decide: Decide = fold()) => (await decide(event, change)) ?? {}
62
62
  const message = (where: Place, unplaced: Unplaced, task: string | undefined, isInTurn = true): SessionChange => {
63
63
  const turn = isInTurn ? (task === undefined ? where.turn : where.taskTurns[task]) : undefined
64
64
  const context = task === undefined ? where.context : (where.tasks[task]?.context ?? where.context)
@@ -79,7 +79,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
79
79
  const answer = await asked(ToolResult, message(where, result, task))
80
80
  const below = await next(e as never)
81
81
  const rewritten = answer.output === undefined || isFailure ? {} : name.startsWith('mcp__') ? { updatedMCPToolOutput: answer.output } : { updatedToolOutput: answer.output }
82
- const failed = answer.isError === true && !isFailure ? { block: answer.note ?? `${name} failed.` } : {}
82
+ const failed = answer.isError === true && !isFailure ? { block: answer.note === undefined ? `${name} failed.` : untagged(answer.note) } : {}
83
83
  return { ...withNote(below, answer.isError === true ? undefined : answer.note), ...rewritten, ...failed }
84
84
  }
85
85
  const added: Disposable[] = [
@@ -136,9 +136,10 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
136
136
  if (!found.some((each) => wanted.has(each.type))) return next(e)
137
137
  const where = await place()
138
138
  const texts = new Map<number, string>()
139
+ const decide = fold()
139
140
  for (const [part, unplaced] of found.entries()) {
140
141
  if (!wanted.has(unplaced.type)) continue
141
- const answer = await asked(unplaced.type, message(where, unplaced, e.agentId))
142
+ const answer = await asked(unplaced.type, message(where, unplaced, e.agentId), decide)
142
143
  if (answer.text !== undefined) texts.set(part, answer.text)
143
144
  }
144
145
  return next(texts.size === 0 ? e : { ...e, message: { ...e.message, content: withTexts(row, texts) as typeof e.message.content } })
@@ -210,14 +211,17 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
210
211
  const where = await place()
211
212
  const task = e.agentId === undefined ? undefined : where.tasks[e.agentId]
212
213
  if (e.agentId !== undefined && task === undefined) return next(e)
213
- const context = task === undefined ? { number: where.context, openedAt: where.contextOpenedAt } : { number: task.context, openedAt: task.contextOpenedAt ?? task.openedAt, task: task.id }
214
- const answer = await asked(ContextEnd, { type: ContextEnd, context: { ...context, closedAt: now() } })
214
+ const answer = await asked(ContextEnd, { type: ContextEnd, context: closingContext(where, task, now()) })
215
215
  return answer.refuse === undefined ? next(e) : { skip: answer.refuse }
216
216
  }),
217
217
  ]
218
218
  return { dispose: () => added.forEach((each) => each.dispose()) }
219
219
  }
220
220
 
221
+ export function closingContext(where: Place, task: RunningTask | undefined, closedAt: string): ContextData {
222
+ return task === undefined ? { number: where.context, openedAt: where.contextOpenedAt, closedAt } : { number: task.context, openedAt: task.contextOpenedAt ?? task.openedAt, closedAt, task: task.id }
223
+ }
224
+
221
225
  export function withAnswer(change: SessionChange, answer: Partial<Answers>): SessionChange {
222
226
  if (change.type === Message && answer.output !== undefined && change.message.type === ToolResult) return { ...change, message: { ...change.message, output: answer.output } }
223
227
  if (change.type === Message && answer.text !== undefined) return { ...change, message: { ...change.message, text: answer.text } as Message }
@@ -1,7 +1,7 @@
1
1
  import type { Args, EventResult, Frozen } from 'claude-code'
2
2
  import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
3
3
  import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
4
- import { Note } from '../session/context/turn/message/note.js'
4
+ import { modNote, Note } from '../session/context/turn/message/note.js'
5
5
  import { Person } from '../session/context/turn/message/sender.js'
6
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
7
  import { Session, UsageChange, type BeforeEvent, type Surface, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
@@ -9,18 +9,19 @@ 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 { foldBefore, installBefore, relayOf, type Place } from './before.js'
12
+ import { beforeAnswerKeys, 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'
16
- import type { Router } from './router.js'
17
- import { actPermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
16
+ import type { RoutedHook, Router } from './router.js'
17
+ import { PassedTimeout } from './deadline.js'
18
+ import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
18
19
 
19
20
  type Request = Readonly<Record<string, unknown>>
20
21
 
21
22
  type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
22
23
 
23
- type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[] }
24
+ type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[]; readonly refusing?: readonly Subscription[] }
24
25
 
25
26
  type Watcher = Watch & { readonly from: string }
26
27
 
@@ -37,6 +38,8 @@ const bufferedTaskRows = 200
37
38
 
38
39
  const beforeDeadlineMs = 5000
39
40
 
41
+ const awayFoldMs = 8000
42
+
40
43
  const livenessMs = 5000
41
44
 
42
45
 
@@ -185,7 +188,7 @@ export function serveConversations({ claude, router, store, project, report }: {
185
188
  reject(error)
186
189
  },
187
190
  }
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`)))
191
+ const timer = deadlineMs === undefined ? watchLife(settle, to) : claude.clock.after(deadlineMs, () => settle.reject(new PassedTimeout(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
189
192
  asked.set(id, settle)
190
193
  post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
191
194
  })
@@ -240,11 +243,24 @@ export function serveConversations({ claude, router, store, project, report }: {
240
243
  return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
241
244
  }
242
245
 
243
- const askAway = async (watcher: Watcher, event: BeforeEvent, change: SessionChange) => {
246
+ const isUnderHere = (task: string, ancestor: string) => {
247
+ for (let at: string | undefined = task, steps = 0; at !== undefined && steps < 64; at = conversation.state.tasks[at]?.parent, steps += 1) if (at === ancestor) return true
248
+ return false
249
+ }
250
+
251
+ const refuses = (watcher: Watcher, event: BeforeEvent, change: SessionChange) =>
252
+ beforeAnswerKeys[event].includes('refuse') && (watcher.refusing ?? []).some(({ event: heard, matcher, scope }) => isInScope(scope ?? {}, session, change, isUnderHere) && isHeardBy(heard, matcher === undefined ? undefined : receivedMatcher(matcher), change))
253
+
254
+ const askAway = async (watcher: Watcher, event: BeforeEvent, change: SessionChange, endsAt: number) => {
244
255
  try {
245
- return await ask(watcher.from, { kind: 'before', mod: watcher.mod, event, change, project: project() }, beforeDeadlineMs)
256
+ const leftMs = Math.min(beforeDeadlineMs, endsAt - (await claude.clock.now()))
257
+ if (leftMs <= 0) throw new PassedTimeout(`the ${awayFoldMs / 1000} s cmod waits on other sessions ran out before session ${watcher.from} was asked`)
258
+ return await ask(watcher.from, { kind: 'before', mod: watcher.mod, event, change, project: project() }, leftMs)
246
259
  } catch (error) {
247
- if (await isRunning(watcher.from)) throw error
260
+ if (await isRunning(watcher.from)) {
261
+ if (error instanceof PassedTimeout && refuses(watcher, event, change)) return { refuse: `${watcher.mod}: ${error.message}` }
262
+ throw error
263
+ }
248
264
  watchers = watchers.filter((each) => each.from !== watcher.from)
249
265
  claude.ui.log(`cmod: session ${watcher.from} ended without clearing its before listeners, so they are cleared now`, { to: 'debug' })
250
266
  await claude.fs.write(`${watches}/${watcher.from}.json`, '[]')
@@ -264,13 +280,20 @@ export function serveConversations({ claude, router, store, project, report }: {
264
280
  await Promise.all([starting, watching])
265
281
  return deciders(event).length > 0
266
282
  },
267
- decide: (event, change) =>
268
- foldBefore(
269
- event,
270
- change,
271
- deciders(event).map((watcher) => (latest: SessionChange) => (watcher.from === session ? askHere(watcher.mod, event, latest) : askAway(watcher, event, latest))),
272
- (error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
273
- ),
283
+ fold: () => {
284
+ let endsAt: Promise<number> | undefined
285
+ return (event, change) =>
286
+ foldBefore(
287
+ event,
288
+ change,
289
+ deciders(event).map((watcher) => async (latest: SessionChange) => {
290
+ if (watcher.from === session) return askHere(watcher.mod, event, latest)
291
+ endsAt ??= claude.clock.now().then((now) => now + awayFoldMs)
292
+ return askAway(watcher, event, latest, await endsAt)
293
+ }),
294
+ (error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
295
+ )
296
+ },
274
297
  },
275
298
  })
276
299
 
@@ -321,22 +344,24 @@ export function serveConversations({ claude, router, store, project, report }: {
321
344
  return next(e)
322
345
  })
323
346
 
324
- router.add('session.append', async (e, next) => {
347
+ const recordAppend: RoutedHook<'session.append'> = async (e, next) => {
325
348
  const kept = await next(e)
326
349
  if (kept.deny !== undefined || session === '') return kept
327
350
  const cwd = e.message.type === 'assistant' ? await claude.session.cwd() : ''
328
351
  void take(rowOfAppend({ uuid: kept.uuid, door: e.door, origin: e.origin, message: kept.message }, new Date().toISOString(), cwd), e.agentId).catch((error: unknown) => report('append', 'Missed a message', error))
329
352
  return kept
330
- })
353
+ }
354
+ router.add('session.append', recordAppend, (e, _error, next) => recordAppend(e, next))
331
355
 
332
- router.add('session.compact', async (e, next) => {
356
+ const recordCompaction: RoutedHook<'session.compact'> = async (e, next) => {
333
357
  const compacted = await next(e)
334
358
  if (e.trigger === 'precompute' || compacted.skip !== undefined || session === '') return compacted
335
359
  const at = new Date().toISOString()
336
360
  const boundary: Row = { id: `compact:${e.agentId ?? session}:${at}`, at, type: 'system', name: 'compact_boundary', isMeta: false, isSummary: false, content: [] }
337
361
  void take(boundary, e.agentId).catch((error: unknown) => report('compact', 'Missed a compaction', error))
338
362
  return compacted
339
- })
363
+ }
364
+ router.add('session.compact', recordCompaction, (e, _error, next) => recordCompaction(e, next))
340
365
 
341
366
  router.add('session.measure', async (e, next) => {
342
367
  const measured = await next(e)
@@ -344,7 +369,7 @@ export function serveConversations({ claude, router, store, project, report }: {
344
369
  return measured
345
370
  })
346
371
 
347
- router.add('agent.spawn', async (e, next) => {
372
+ const recordSpawn: RoutedHook<'agent.spawn'> = async (e, next) => {
348
373
  const spawned = await next(e)
349
374
  if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
350
375
  const id = spawned.agentId
@@ -355,7 +380,8 @@ export function serveConversations({ claude, router, store, project, report }: {
355
380
  waitingRows.delete(id)
356
381
  for (const row of waiting) dispatch(session, conversation.add(row, id))
357
382
  return spawned
358
- })
383
+ }
384
+ router.add('agent.spawn', recordSpawn, (e, _error, next) => recordSpawn(e, next))
359
385
 
360
386
  router.add('session.receive', async (e, next) => {
361
387
  const sent = relayOf(e)
@@ -398,13 +424,13 @@ export function serveConversations({ claude, router, store, project, report }: {
398
424
  await checkAct(caller, action, input)
399
425
  await starting
400
426
  if (action === 'abort') return abortTurn(input, task)
401
- if (task !== undefined) return performInTask(action, input, task)
427
+ if (task !== undefined) return performInTask(caller, action, input, task)
402
428
  if (action === 'usage') return usageOf(claude)
403
429
  if (action === 'surfaces') return [...(await claude.session.surfaces())] satisfies Surface[]
404
430
  if (action === 'send') {
405
431
  const { type, text, as } = input as SessionSend
406
432
  if (as === Session) throw new Error(`session ${session} cannot send as itself to itself.`)
407
- const delivered = type === Note ? await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }) : await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
433
+ const delivered = type === Note ? await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text: modNote(caller, text) }] } }) : await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
408
434
  if ('deny' in delivered && typeof delivered.deny === 'string') throw new Error(`Claude Code refused the note: ${delivered.deny}`)
409
435
  if ('drop' in delivered && typeof delivered.drop === 'string') throw new Error(`Claude Code dropped the prompt: ${delivered.drop}`)
410
436
  return null
@@ -424,13 +450,14 @@ export function serveConversations({ claude, router, store, project, report }: {
424
450
  return null
425
451
  }
426
452
 
427
- const performInTask = async (action: Act, input: unknown, task: string): Promise<unknown> => {
453
+ const performInTask = async (caller: string, action: Act, input: unknown, task: string): Promise<unknown> => {
428
454
  const running = conversation.state.tasks[task]
429
455
  if (action === 'usage') return json(['history', 'usage', session, '--task', task])
430
456
  if (action === 'surfaces') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
431
457
  if (action !== 'send') throw new Error(`cmod: ${action} runs only on a session's own main loop.`)
432
458
  if (running === undefined || running.closedAt !== undefined) throw ended(task)
433
- const sent = await claude.session.send({ to: { agentId: task }, text: (input as SessionSend).text })
459
+ const { type, text } = input as SessionSend
460
+ const sent = await claude.session.send({ to: { agentId: task }, text: type === Note ? modNote(caller, text) : text })
434
461
  if (sent.isDelivered !== true) throw ended(task)
435
462
  return null
436
463
  }
@@ -567,7 +594,7 @@ export function serveConversations({ claude, router, store, project, report }: {
567
594
  if (caller === undefined) throw new Error('cmod hears no caller for this before listener.')
568
595
  const target = String(request['session'])
569
596
  const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
570
- ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))] })
597
+ ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))], refusing: list.filter((each) => each.onFailure === 'refuse') })
571
598
  await publishWatches()
572
599
  return null
573
600
  },
@@ -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,7 +1,7 @@
1
1
  import type { ClassicHookEvent, ClassicHookInputs } from 'claude-code'
2
2
  import { configFolders } from '../records.js'
3
3
  import type { Claude } from './claude.js'
4
- import type { RoutedHook } from './router.js'
4
+ import type { RoutedFailure, RoutedHook } from './router.js'
5
5
  import type { Failure } from './toaster.js'
6
6
  import { callEffects, type CallBase, type FileAccess } from '../utils/call-effects.js'
7
7
  import { dynamicPattern } from '../utils/parse-shell.js'
@@ -35,8 +35,6 @@ export type RoutedEvent =
35
35
  | 'agent.spawn'
36
36
  | 'turn.start'
37
37
 
38
- export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
39
-
40
38
  type CallFiles = { readonly read: string[]; readonly changed: string[] }
41
39
 
42
40
  export function classicHook<H extends Hook>(event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
@@ -53,6 +51,14 @@ export function classicHook<H extends Hook>(event: H, hook: HookListener<H>, rep
53
51
  return routed as unknown as RoutedHook<RoutedEvent>
54
52
  }
55
53
 
54
+ export function classicFailure<H extends Hook>(failed: (input: ClassicHookInputs[H], error: Error) => HookFields | undefined | Promise<HookFields | undefined>): RoutedFailure<RoutedEvent> {
55
+ const routed = async (e: unknown, error: Error, next: (e: unknown) => Promise<unknown>) => {
56
+ const ours = await failed(e as ClassicHookInputs[H], error)
57
+ return ours === undefined ? undefined : mergeClassic((await next(e)) as HookFields, ours)
58
+ }
59
+ return routed as unknown as RoutedFailure<RoutedEvent>
60
+ }
61
+
56
62
  const frontmatter = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/
57
63
 
58
64
  const baseDirectoryLine = /^Base directory for this skill: [^\n]*\n\n/
@@ -1,10 +1,10 @@
1
- import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement, Timer } from 'claude-code'
1
+ import type { Args, ClassicHookInputs, EventResult, Frozen, HookBudget, HookFailure, PluginOptions, RenderElement, Timer } from 'claude-code'
2
2
  import type { Step } from '../installer.js'
3
3
  import type { Allowed, FileChange, Disposable, Imports, Mod, ModDefinition, PaneHandle, SettingsFile, SettingsFileChange, SettingsFileChangeListener, Toast } from '../mod.js'
4
4
  import type { Options, OptionValues } from '../options.js'
5
5
  import { changelogPage, consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
6
6
  import { homePrefix, ifMissing, relativePath } from '../utils/paths.js'
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,7 @@ 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 { classicFailure, classicHook, userSkillHook, type Hook, type HookFields, type HookListener, type RoutedEvent } from './hooks.js'
16
16
  import type { PermissionRoutes } from './permission-check.js'
17
17
  import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
18
18
  import { cmodError, lastLineOf, locatingPrograms, readLines, type Programs } from './programs.js'
@@ -21,7 +21,7 @@ import { fitsOption } from '../options.js'
21
21
  import { createInstaller } from './installer.js'
22
22
  import { createOptions, MissingOptions } from './options.js'
23
23
  import { createRouter, type Router, type RouterNext } from './router.js'
24
- import { conversationMethods, createSessionsClient } from './sessions.js'
24
+ import { conversationMethods, createSessionsClient, type SessionsClient } from './sessions.js'
25
25
  import type { SessionChange } from '../session/conversation.js'
26
26
  import type { BeforeEvent } from '../session/session.js'
27
27
  import { matches } from '../utils/matcher.js'
@@ -52,6 +52,7 @@ export type Lifecycle<State extends object> = {
52
52
  readonly toasts: readonly ShownToast[]
53
53
  start(claude: Claude, read: (claude: Claude) => Promise<Plugin>): Promise<void>
54
54
  route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
55
+ caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, failure: HookFailure): Promise<EventResult<N>>
55
56
  raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
56
57
  }
57
58
 
@@ -72,7 +73,7 @@ type ActiveMod<State extends object, Declared extends Options> = {
72
73
  readonly mod: Mod<State, Declared>
73
74
  readonly added: readonly string[]
74
75
  readonly imports: Imports
75
- readonly raise: (method: string, payload: Readonly<Record<string, unknown>>) => Promise<unknown>
76
+ readonly raise: SessionsClient['raise']
76
77
  }
77
78
 
78
79
 
@@ -88,7 +89,9 @@ const cmodWaitMs = 60_000
88
89
 
89
90
  const cmodSlowCheckMs = 10_000
90
91
 
91
- const startHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
92
+ const hookMs = 10_000 satisfies HookBudget['ms']
93
+
94
+ const startHoldMs = hookMs - 1_000
92
95
 
93
96
  const dependencyCallMs = 30_000
94
97
 
@@ -169,7 +172,7 @@ export function createLifecycle<State extends object, Declared extends Options =
169
172
  let activation: Promise<void> | undefined
170
173
  let mod: Mod<State> | undefined
171
174
  let imports: Imports | undefined
172
- let raise: ActiveMod<State, Declared>['raise'] | undefined
175
+ let raise: SessionsClient['raise'] | undefined
173
176
  let failure: unknown
174
177
  let pluginOptions = options
175
178
  const installer = createInstaller(definition.name, () => claude())
@@ -628,6 +631,16 @@ export function createLifecycle<State extends object, Declared extends Options =
628
631
  if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined) return answer
629
632
  return runtime.toaster.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.props, render.requestId) as EventResult<N>
630
633
  },
634
+ async caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, { kind, message }: HookFailure): Promise<EventResult<N>> {
635
+ const call = e as Frozen<Args<'cmod.call'>>
636
+ const isAsk = event === 'cmod.call' && call.to === definition.name && beforeAskOf(call) !== undefined
637
+ const reasons: Readonly<Record<string, string>> = { timeout: `it ran past the ${hookMs / 1000} s Claude Code gives a hook`, throw: message ?? 'it threw' }
638
+ const reason = reasons[kind]
639
+ if (reason === undefined) return isAsk ? ({ value: null } as EventResult<N>) : next(e)
640
+ const error = new Error(reason)
641
+ toastFailure({ id: `stopped:${event}`, message: `Claude Code stopped its ${event.replace(/^classic\./, '')} hook`, error })
642
+ return router.dispatchFailure(event, e, error, next)
643
+ },
631
644
  }
632
645
  }
633
646
 
@@ -704,7 +717,8 @@ async function createMod<State extends object, Declared extends Options>(definit
704
717
  }))
705
718
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
706
719
  const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
707
- const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>): Disposable => router.add(`classic.${hook}`, classicHook(hook, listener, report))
720
+ const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>, failed?: (input: ClassicHookInputs[H], error: Error) => HookFields | undefined): Disposable =>
721
+ router.add(`classic.${hook}`, classicHook(hook, listener, report), failed === undefined ? undefined : classicFailure(failed))
708
722
  const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
709
723
  const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
710
724
  read: () => checked.settings.read({ source }),
@@ -718,8 +732,8 @@ async function createMod<State extends object, Declared extends Options>(definit
718
732
  claude: checked,
719
733
  report: (id, message, error) => report({ id, message, error }),
720
734
  isGranted: (permission) => flag(permission),
721
- hookOn: (hook, listener) => hookOn(hook as Hook, listener as HookListener<Hook>),
722
- routeOn: (event, hook) => router.add(event, hook as never),
735
+ hookOn: (hook, listener, failed) => hookOn(hook as Hook, listener as HookListener<Hook>, failed),
736
+ routeOn: (event: RoutedEvent, hook: unknown, failed?: unknown) => router.add(event, hook as never, failed as never),
723
737
  permissionRoutes: runtime.permissionRoutes,
724
738
  })
725
739
  let followed: Parameters<typeof client.follow>[0] | undefined
@@ -944,10 +958,8 @@ async function createMod<State extends object, Declared extends Options>(definit
944
958
  client.changed(changedSession, change, isInProject !== false)
945
959
  return { value: null }
946
960
  }
947
- if (e.method === conversationMethods.before && call.caller === cmodPluginName) {
948
- const { session: asking, event, change, isInProject } = e.input as { readonly session: string; readonly event: BeforeEvent; readonly change: SessionChange; readonly isInProject?: boolean }
949
- return { value: (await client.answerBefore(asking, event, change, isInProject !== false)) ?? null }
950
- }
961
+ const ask = beforeAskOf(call)
962
+ if (ask !== undefined) return { value: (await client.answerBefore(ask.session, ask.event, ask.change, ask.isInProject)) ?? null }
951
963
  if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
952
964
  if (e.method === openPageMethod) {
953
965
  await settingsPages.open(String(e.input))
@@ -955,9 +967,13 @@ async function createMod<State extends object, Declared extends Options>(definit
955
967
  }
956
968
  const served = await conversations?.answer(call)
957
969
  return served ?? answerCall(definition.name, api, e)
970
+ }, (e, error) => {
971
+ if (e.to !== definition.name) return undefined
972
+ const ask = beforeAskOf(e)
973
+ return ask === undefined ? { deny: `${definition.name}: ${error.message}` } : { value: client.failedBefore(ask.session, ask.event, ask.change, ask.isInProject, error) ?? null }
958
974
  })
959
975
  const agents = toolCalls(claude, router)
960
- keepEngine(mod, { claude: checked, router, toolCalls: agents, report })
976
+ keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined })
961
977
  const names = new Set<string>()
962
978
  const reserveName = (type: string, name: string, taken: string): Disposable => {
963
979
  const key = `${type}:${name}`
@@ -972,10 +988,6 @@ async function createMod<State extends object, Declared extends Options>(definit
972
988
  await failsAs('the details of its session did not load', () => followCurrent(undefined))
973
989
  await failsAs('its setup function threw', () => definition.setup(mod))
974
990
  isSetUp = true
975
- const permissionHooks = permissionEvents.filter((event) => router.has(event))
976
- if (permissionHooks.length > 0 && runtime.permissionRoutes() === undefined) {
977
- throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
978
- }
979
991
  await failsAs('its open panes did not load', () => area.restorePanes())
980
992
  return { mod, added, imports, raise: client.raise }
981
993
  }
@@ -1001,6 +1013,14 @@ function checkedMs(name: string, subject: string, ms: number): number {
1001
1013
  return ms
1002
1014
  }
1003
1015
 
1016
+ type BeforeAsk = { readonly session: string; readonly event: BeforeEvent; readonly change: SessionChange; readonly isInProject: boolean }
1017
+
1018
+ function beforeAskOf(call: Frozen<Args<'cmod.call'>> & { readonly caller?: string }): BeforeAsk | undefined {
1019
+ if (call.method !== conversationMethods.before || call.caller !== cmodPluginName) return undefined
1020
+ const { session, event, change, isInProject } = call.input as Omit<BeforeAsk, 'isInProject'> & { readonly isInProject?: boolean }
1021
+ return { session, event, change, isInProject: isInProject !== false }
1022
+ }
1023
+
1004
1024
  async function failsAs(subject: string, step: () => unknown): Promise<void> {
1005
1025
  try {
1006
1026
  await step()