@cmodjs/core 1.5.1 → 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 (130) hide show
  1. package/docs/commands.md +5 -5
  2. package/docs/index.md +12 -11
  3. package/docs/jobs.md +12 -16
  4. package/docs/mod.md +117 -39
  5. package/docs/permissions.md +17 -21
  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 +3 -1
  26. package/records.d.ts.map +1 -1
  27. package/records.js +14 -5
  28. package/register.d.ts.map +1 -1
  29. package/register.js +3 -1
  30. package/runtime/before.d.ts +5 -1
  31. package/runtime/before.d.ts.map +1 -1
  32. package/runtime/before.js +81 -39
  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 +45 -26
  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 +1 -1
  44. package/runtime/grants.d.ts.map +1 -1
  45. package/runtime/grants.js +19 -12
  46. package/runtime/hooks.d.ts +28 -11
  47. package/runtime/hooks.d.ts.map +1 -1
  48. package/runtime/hooks.js +20 -123
  49. package/runtime/lifecycle.d.ts +2 -1
  50. package/runtime/lifecycle.d.ts.map +1 -1
  51. package/runtime/lifecycle.js +67 -50
  52. package/runtime/sessions.d.ts +31 -5
  53. package/runtime/sessions.d.ts.map +1 -1
  54. package/runtime/sessions.js +342 -81
  55. package/runtime/tool-calls.d.ts +0 -1
  56. package/runtime/tool-calls.d.ts.map +1 -1
  57. package/runtime/tool-calls.js +0 -19
  58. package/session/context/turn/message/instruction-file.d.ts +9 -0
  59. package/session/context/turn/message/instruction-file.d.ts.map +1 -0
  60. package/session/context/turn/message/instruction-file.js +4 -0
  61. package/session/context/turn/message/message.d.ts +5 -4
  62. package/session/context/turn/message/message.d.ts.map +1 -1
  63. package/session/context/turn/message/message.js +7 -6
  64. package/session/context/turn/message/sender.d.ts +2 -1
  65. package/session/context/turn/message/sender.d.ts.map +1 -1
  66. package/session/context/turn/message/sender.js +4 -1
  67. package/session/context/turn/message/tool-call.d.ts +5 -0
  68. package/session/context/turn/message/tool-call.d.ts.map +1 -1
  69. package/session/context/turn/message/tool-result.d.ts +6 -1
  70. package/session/context/turn/message/tool-result.d.ts.map +1 -1
  71. package/session/context/turn/turn.d.ts +7 -0
  72. package/session/context/turn/turn.d.ts.map +1 -1
  73. package/session/conversation.d.ts +10 -3
  74. package/session/conversation.d.ts.map +1 -1
  75. package/session/conversation.js +14 -5
  76. package/session/goal/goal.d.ts +1 -1
  77. package/session/goal/goal.d.ts.map +1 -1
  78. package/session/goal/task.d.ts +2 -2
  79. package/session/goal/task.d.ts.map +1 -1
  80. package/session/session.d.ts +361 -26
  81. package/session/session.d.ts.map +1 -1
  82. package/session/session.js +40 -0
  83. package/src/jobs/permissions/decide-permission.ts +2 -1
  84. package/src/jobs/prompt.ts +3 -20
  85. package/src/jobs/slash-command.ts +2 -2
  86. package/src/jobs/status-line.ts +4 -7
  87. package/src/mod.ts +52 -147
  88. package/src/records.ts +15 -5
  89. package/src/register.ts +3 -1
  90. package/src/runtime/before.ts +90 -37
  91. package/src/runtime/claude.ts +5 -1
  92. package/src/runtime/conversations.ts +41 -25
  93. package/src/runtime/deadline.ts +38 -0
  94. package/src/runtime/dependencies.ts +12 -0
  95. package/src/runtime/grants.ts +15 -10
  96. package/src/runtime/hooks.ts +48 -116
  97. package/src/runtime/lifecycle.ts +66 -51
  98. package/src/runtime/sessions.ts +366 -90
  99. package/src/runtime/tool-calls.ts +0 -19
  100. package/src/session/context/turn/message/instruction-file.ts +13 -0
  101. package/src/session/context/turn/message/message.ts +11 -9
  102. package/src/session/context/turn/message/sender.ts +5 -2
  103. package/src/session/context/turn/message/tool-call.ts +3 -0
  104. package/src/session/context/turn/message/tool-result.ts +6 -1
  105. package/src/session/context/turn/turn.ts +4 -0
  106. package/src/session/conversation.ts +21 -8
  107. package/src/session/goal/goal.ts +1 -1
  108. package/src/session/goal/task.ts +2 -2
  109. package/src/session/session.ts +227 -24
  110. package/src/testing/claude-code.ts +23 -0
  111. package/src/testing/fake-claude.ts +5 -1
  112. package/src/testing/fake-sessions.ts +38 -24
  113. package/src/testing.ts +84 -16
  114. package/testing/claude-code.d.ts +12 -0
  115. package/testing/claude-code.d.ts.map +1 -0
  116. package/testing/claude-code.js +10 -0
  117. package/testing/fake-claude.d.ts +4 -0
  118. package/testing/fake-claude.d.ts.map +1 -1
  119. package/testing/fake-claude.js +4 -1
  120. package/testing/fake-sessions.d.ts +8 -6
  121. package/testing/fake-sessions.d.ts.map +1 -1
  122. package/testing/fake-sessions.js +35 -20
  123. package/testing.d.ts +38 -9
  124. package/testing.d.ts.map +1 -1
  125. package/testing.js +54 -5
  126. package/docs/hooks.md +0 -259
  127. package/session/context/turn/message/document.d.ts +0 -7
  128. package/session/context/turn/message/document.d.ts.map +0 -1
  129. package/session/context/turn/message/document.js +0 -1
  130. package/src/session/context/turn/message/document.ts +0 -8
@@ -1,18 +1,23 @@
1
+ import type { Args, CommandRunResult } from 'claude-code'
1
2
  import type { Disposable } from '../mod.js'
2
3
  import { ContextEnd, ContextStart, type Context } from '../session/context/context.js'
3
- import { Message } from '../session/context/turn/message/message.js'
4
+ import { Message, noFiles } from '../session/context/turn/message/message.js'
4
5
  import { Note } from '../session/context/turn/message/note.js'
5
- import { Person } from '../session/context/turn/message/sender.js'
6
+ import { Person, senderOf } from '../session/context/turn/message/sender.js'
7
+ import { ToolCall } from '../session/context/turn/message/tool-call.js'
6
8
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
7
9
  import { TurnEnd, TurnStart, type Turn } from '../session/context/turn/turn.js'
8
- import type { ContextData, GoalData, SessionChange, SessionData, TaskData, TurnData } from '../session/conversation.js'
10
+ import { searchLimit, type ContextData, type GoalData, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
9
11
  import { GoalChange, type Goal } from '../session/goal/goal.js'
10
12
  import { TaskEnd, TaskStart, type Task } from '../session/goal/task.js'
11
- import { Session, type BeforeEvent, type Client, type RecordPart, type SearchOptions, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskStartArgs, type ToolCallArgs, type Usage } from '../session/session.js'
12
- import { matches, sentMatcher, type SentMatcher } from '../utils/matcher.js'
13
+ import { beforeEvents, processEvents, Session, sessionEvents, type BeforeEvent, type Placement, type BeforeMethods, type Surface, type OnMethods, type ProcessBeforeMethods, type ProcessName, type ProcessOnMethods, type RecordPart, type SearchOptions, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskOwner, type TaskStartArgs, type ToolCallArgs, type Usage } from '../session/session.js'
14
+ import { matches, sentMatcher, type Matcher, type SentMatcher } from '../utils/matcher.js'
13
15
  import { foldAnswers, type Answers, type Place } from './before.js'
14
16
  import type { Claude } from './claude.js'
15
17
  import { cmodPluginName } from './dependencies.js'
18
+ import { messageOf } from '../utils/text.js'
19
+ import { listenerOf, PassedTimeout, withinTime, type ListenOptions, type ListenTime } from './deadline.js'
20
+ import { callFiles, type Hook, type HookAnswer } from './hooks.js'
16
21
 
17
22
  export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
18
23
 
@@ -35,17 +40,19 @@ export const conversationMethods = {
35
40
  before: 'cmod:before',
36
41
  } as const
37
42
 
38
- export type Act = 'send' | 'startTask' | 'callTool' | 'usage' | 'clients'
43
+ export type Act = 'send' | 'startTask' | 'callTool' | 'usage' | 'surfaces' | 'abort'
39
44
 
40
- const actPermissions: Readonly<Record<Exclude<Act, 'callTool'>, string>> = { send: 'prompt', startTask: 'tasks', usage: 'conversation', clients: 'conversation' }
45
+ const actPermissions: Readonly<Record<Exclude<Act, 'callTool'>, string>> = { send: 'prompt', startTask: 'tasks', usage: 'conversation', surfaces: 'conversation', abort: 'prompt' }
41
46
 
42
47
  const startsTurns: readonly string[] = ['CronCreate']
43
48
 
44
49
  const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
45
50
 
46
- export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart || event === TaskEnd ? 'tasks' : 'prompt')
51
+ export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart ? 'tasks' : 'prompt')
47
52
 
48
- export const isAct =(action: string): action is Act => action === 'callTool' || action in actPermissions
53
+ const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
54
+
55
+ export const isAct = (action: string): action is Act => action === 'callTool' || action in actPermissions
49
56
 
50
57
  export function toolPermission(tool: string): string | undefined {
51
58
  return startsTurns.includes(tool) ? 'prompt' : ownSchedule.includes(tool) ? undefined : 'tools'
@@ -55,26 +62,26 @@ export function actPermission(action: Act, input: unknown): string | undefined {
55
62
  return action === 'callTool' ? (toolPermission(String((input as Partial<ToolCallArgs> | null)?.name)) ?? 'tools') : actPermissions[action]
56
63
  }
57
64
 
58
- export type Scope = { readonly session?: string; readonly context?: number; readonly turn?: string; readonly task?: string; readonly isGoal?: true }
65
+ export type Scope = { readonly session?: string; readonly context?: number; readonly turn?: string; readonly task?: string; readonly isGoal?: true; readonly isAgent?: true }
59
66
 
60
67
  export type SessionsClient = {
61
68
  readonly sessions: Sessions
62
69
  changed(session: string, change: SessionChange, isInProject: boolean): void
63
70
  answerBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<unknown>
71
+ raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
72
+ listen(method: string, args: readonly unknown[]): Disposable
64
73
  /** An empty title keeps the title the session already has. */
65
74
  follow(current: Omit<SessionData, 'isRunning' | 'lastAt'>): void
66
75
  }
67
76
 
68
- type Listening = { scope: Scope; readonly event: SessionEvent; readonly matcher: unknown; readonly listener: (payload: never) => unknown }
77
+ type Listening = { scope: Scope; readonly event: SessionEvent; readonly method: string; readonly matcher: unknown; readonly listener: (payload: never, time: ListenTime) => unknown; readonly options: ListenOptions }
69
78
 
70
79
  type Owned = { readonly session: string; readonly task: string }
71
80
 
72
- const asked = (action: string) => `cmod: ${action} runs only on a session's own main loop, and Claude Code gives a subagent's tasks no way to start a task or run a tool.`
73
-
74
81
  export async function usageOf(claude: Pick<Claude, 'session'>): Promise<Usage> {
75
82
  const [model, usage] = await Promise.all([claude.session.model(), claude.session.usage()])
76
83
  const { tokens, window, percent } = usage.context
77
- return { model, context: { ...(tokens === undefined ? {} : { tokens }), window, ...(percent === undefined ? {} : { percent }) }, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) }
84
+ return { model, contextWindow: { ...(tokens === undefined ? {} : { tokenCount: tokens }), size: window, ...(percent === undefined ? {} : { percent }) }, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) }
78
85
  }
79
86
 
80
87
  export function payloadOf(change: SessionChange): unknown {
@@ -119,15 +126,15 @@ function contextOfChange(change: SessionChange): number | undefined {
119
126
  export function isInScope(scope: Scope, session: string, change: SessionChange, isUnder: (task: string, ancestor: string) => boolean): boolean {
120
127
  if (scope.session !== undefined && scope.session !== session) return false
121
128
  const task = taskOfChange(change)
129
+ if (scope.isAgent === true) return (change.type === TaskStart || change.type === TaskEnd ? change.task.parent : task) === scope.task
122
130
  if (scope.task !== undefined && (task === undefined || !isUnder(task, scope.task))) return false
123
- if (scope.isGoal === true && change.type !== GoalChange && change.type !== TaskStart && (task === undefined || task === scope.task)) return false
124
131
  if (scope.context !== undefined && (contextOfChange(change) !== scope.context || ownerOfChange(change) !== scope.task)) return false
125
132
  if (scope.turn === undefined) return true
126
133
  if (change.type === Message) return change.message.turn === scope.turn
127
134
  return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
128
135
  }
129
136
 
130
- export function createSessionsClient({ name, claude, report, isGranted }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean }): SessionsClient {
137
+ export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean; readonly hookOn?: (hook: string, listener: (input: unknown) => unknown) => Disposable; readonly routeOn?: RouteOn }): SessionsClient {
131
138
  const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
132
139
  const listening = new Map<string, Set<Listening>>()
133
140
  const deciding = new Map<string, Set<Listening>>()
@@ -168,11 +175,9 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
168
175
  if (change.type === TaskStart) parents.get(session)?.set(change.task.id, change.task.parent)
169
176
  }
170
177
 
171
- const add = (into: Map<string, Set<Listening>>, scope: Scope, event: SessionEvent, matcherOrListener: unknown, maybeListener: unknown, onChange: (key: string) => void): Disposable => {
172
- const listener = (maybeListener ?? matcherOrListener) as (payload: never) => unknown
173
- const matcher = maybeListener === undefined ? undefined : matcherOrListener
178
+ const add = (into: Map<string, Set<Listening>>, scope: Scope, event: SessionEvent, method: string, args: readonly unknown[], onChange: (key: string) => void): Disposable => {
174
179
  const key = keyOf(scope)
175
- const entry: Listening = { scope, event, matcher, listener }
180
+ const entry: Listening = { scope, event, method, ...listenerOf(name, method, args) }
176
181
  const set = into.get(key) ?? new Set()
177
182
  set.add(entry)
178
183
  into.set(key, set)
@@ -198,25 +203,66 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
198
203
  .map((entry) => async (latest: SessionChange) => {
199
204
  if (!isInScope(entry.scope, session, latest, isUnderIn(session))) return undefined
200
205
  if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
201
- const answer = await entry.listener(payloadFor(session, latest) as never)
202
- if (answer === undefined || isGranted(beforePermission(event))) return answer
203
- throw new Error(`Its before(${event}) answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
206
+ const payload = await payloadFor(session, latest)
207
+ const answer = await withinTime(claude.clock, entry.method, entry.options, (time) => entry.listener(payload as never, time)).catch((error: unknown) => {
208
+ if (entry.options.onFailure !== 'refuse') throw error
209
+ report(`session:${entry.method}`, `The ${entry.method} listener failed`, error)
210
+ return { refuse: `${name}: ${messageOf(error)}` }
211
+ })
212
+ if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
213
+ throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
204
214
  })
205
- return foldAnswers(event, change, askers, (error) => report(`before:${event}`, `The before(${event}) listener failed`, error))
215
+ return foldAnswers(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
216
+ }
217
+
218
+ const eventMethods = (scopeOf: () => Scope, hasBefore: boolean, ended?: string): OnMethods & Partial<BeforeMethods> => {
219
+ const method = (into: Map<string, Set<Listening>>, named: string, event: SessionEvent) => (...args: unknown[]) => {
220
+ if (ended !== undefined) throw new Error(`${name}: ${ended} has ended, so its ${named} never fires. Listen on its Session.`)
221
+ return add(into, scopeOf(), event, named, args, subscribe)
222
+ }
223
+ return Object.fromEntries([
224
+ ...Object.entries(sessionEvents).map(([named, event]) => [`on${named}`, method(listening, `on${named}`, event)]),
225
+ ...(hasBefore ? beforeEvents.map((named) => [`before${named}`, method(deciding, `before${named}`, sessionEvents[named])]) : []),
226
+ ]) as OnMethods & Partial<BeforeMethods>
227
+ }
228
+
229
+ const withFiles = async (message: Message): Promise<Message> => {
230
+ if (message.type !== ToolCall && message.type !== ToolResult) return message
231
+ return { ...message, files: await callFiles(name, claude, { tool: message.name, input: message.input }, message.cwd) }
206
232
  }
207
233
 
208
- const recordPart = (scope: Scope): RecordPart => ({
209
- search: (text: string, options: SearchOptions = {}) => call(conversationMethods.search, { text, ...scope, ...optionsOf(options) }),
210
- on: (event: SessionEvent, matcherOrListener: unknown, maybeListener?: unknown) => add(listening, scope, event, matcherOrListener, maybeListener, subscribe),
211
- before: (event: BeforeEvent, matcherOrListener: unknown, maybeListener?: unknown) => add(deciding, scope, event, matcherOrListener, maybeListener, subscribe),
212
- }) as RecordPart
234
+ const messagesFrom = async (method: string, input: unknown) => Promise.all((await call<Message[]>(method, input)).map(withFiles))
235
+
236
+ const searched = (request: Readonly<Record<string, unknown>>, matcher: unknown): AsyncIterable<Message> => ({
237
+ async *[Symbol.asyncIterator]() {
238
+ let before = request['before']
239
+ for (;;) {
240
+ const page = await messagesFrom(conversationMethods.search, { ...request, ...(before === undefined ? {} : { before }) })
241
+ for (const message of page) if (matcher === undefined || matches(message, matcher)) yield message
242
+ const last = page.at(-1)
243
+ if (last === undefined || page.length < searchLimit) return
244
+ before = { session: last.session, id: last.id }
245
+ }
246
+ },
247
+ })
248
+
249
+ const recordPart = (scope: Scope, ended?: string): RecordPart =>
250
+ ({
251
+ search: (text: string, matcher?: unknown) => searched({ text, ...scope }, matcher),
252
+ ...eventMethods(() => scope, true, ended),
253
+ }) as RecordPart
213
254
 
214
255
  const turnOf = (scope: Scope, data: TurnData): Turn => {
215
256
  const task = data.prompt.task ?? scope.task
216
257
  return {
217
258
  ...data,
218
- ...recordPart({ ...scope, ...(task === undefined ? {} : { task }), turn: data.prompt.id }),
219
- messages: () => call<Message[]>(conversationMethods.messages, { session: scope.session, turn: data.prompt.id, ...(task === undefined ? {} : { task }) }),
259
+ ...recordPart({ ...scope, ...(task === undefined ? {} : { task }), turn: data.prompt.id }, data.closedAt === undefined ? undefined : `turn ${data.prompt.id}`),
260
+ messages: () => messagesFrom(conversationMethods.messages, { session: scope.session, turn: data.prompt.id, ...(task === undefined ? {} : { task }) }),
261
+ abort: async () => {
262
+ if (data.closedAt !== undefined) throw new Error(`${name}: turn ${data.prompt.id} has ended, so there is nothing to abort.`)
263
+ const session = scope.session ?? current.id
264
+ await act(session, 'abort', { turn: data.prompt.id }, task === undefined ? undefined : { session, task })
265
+ },
220
266
  }
221
267
  }
222
268
 
@@ -224,7 +270,7 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
224
270
  const owned: Scope = { ...(scope.session === undefined ? {} : { session: scope.session }), ...(data.task === undefined ? {} : { task: data.task }) }
225
271
  return {
226
272
  ...data,
227
- ...recordPart({ ...owned, context: data.number }),
273
+ ...recordPart({ ...owned, context: data.number }, data.closedAt === undefined ? undefined : `context ${data.number}`),
228
274
  turns: async () => (await call<TurnData[]>(conversationMethods.turns, { ...owned, context: data.number })).map((turn) => turnOf(owned, turn)),
229
275
  }
230
276
  }
@@ -238,27 +284,34 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
238
284
  owner: ownerOf(session, data),
239
285
  ...recordPart({ session, task: data.id }),
240
286
  tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session, task: data.id })).map((task) => taskOf(session, task)),
241
- messages: () => call<Message[]>(conversationMethods.messages, { session, task: data.id }),
287
+ messages: () => messagesFrom(conversationMethods.messages, { session, task: data.id }),
242
288
  })
243
289
 
244
- const goalOf = (scope: Scope, data: GoalData): Goal => ({
245
- ...data,
246
- ...recordPart({ ...scope, isGoal: true }),
247
- tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session: scope.session, ...(scope.task === undefined ? {} : { task: scope.task }) })).map((task) => taskOf(scope.session ?? current.id, task)),
248
- update: async ({ text }) => {
249
- if (text.trim() === '') throw new Error(`${name}: a goal needs text.`)
250
- await call(conversationMethods.updateGoal, { session: scope.session ?? current.id, ...(scope.task === undefined ? {} : { task: scope.task }), text })
251
- },
252
- })
290
+ const goalOf = (where: () => Scope): Goal =>
291
+ ({
292
+ search: (text: string, matcher?: unknown) => searched({ text, ...where() }, matcher),
293
+ ...eventMethods(() => ({ ...where(), isGoal: true }), true),
294
+ text: async () => (await call<GoalData>(conversationMethods.goal, where())).text,
295
+ tasks: async () => {
296
+ const { session = current.id, task } = where()
297
+ return (await call<TaskData[]>(conversationMethods.tasks, { session, ...(task === undefined ? {} : { task }) })).map((each) => taskOf(session, each))
298
+ },
299
+ update: async ({ text }: { readonly text: string }) => {
300
+ if (text.trim() === '') throw new Error(`${name}: a goal needs text.`)
301
+ const { session = current.id, task } = where()
302
+ await call(conversationMethods.updateGoal, { session, ...(task === undefined ? {} : { task }), text })
303
+ },
304
+ }) as Goal
253
305
 
254
- const payloadFor = (session: string, change: SessionChange): unknown => {
306
+ const payloadFor = async (session: string, change: SessionChange): Promise<unknown> => {
255
307
  const scope = { session }
256
- if (change.type === Message) return change.message
308
+ if (change.type === Message) return withFiles(change.message)
257
309
  if ('turn' in change) return turnOf(scope, change.turn)
258
310
  if ('context' in change) return contextOf(scope, change.context)
259
311
  if ('task' in change) return taskOf(session, change.task)
260
312
  if ('usage' in change) return change.usage
261
- return goalOf({ session, ...(change.goal.task === undefined ? {} : { task: change.goal.task }) }, { text: change.goal.text })
313
+ const { task } = change.goal
314
+ return goalOf(() => ({ session, ...(task === undefined ? {} : { task }) }))
262
315
  }
263
316
 
264
317
  const act = <Answer>(session: string, action: Act, input: unknown, owned?: Owned) => call<Answer>(conversationMethods.act, { session: owned?.session ?? session, action, input, ...(owned === undefined ? {} : { task: owned.task }) })
@@ -274,27 +327,17 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
274
327
  if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`)
275
328
  }
276
329
 
277
- const startHere = async (task: TaskStartArgs): Promise<Task> => {
278
- const started = await claude.agent.spawn({
279
- prompt: task.prompt,
280
- ...(task.name === undefined ? {} : { description: task.name, name: task.name }),
281
- ...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
282
- ...(task.model === undefined ? {} : { model: task.model }),
283
- ...(task.cwd === undefined ? {} : { cwd: task.cwd }),
284
- })
285
- if (started.deny !== undefined) throw new Error(`${name}: Claude Code refused the task: ${started.deny}`)
286
- return taskOf(current.id, { id: started.agentId ?? '', title: task.name ?? '', agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() })
287
- }
330
+ const startHere = async (task: TaskStartArgs): Promise<Task> => taskOf(current.id, await spawnTask(claude, task).catch((error: unknown) => Promise.reject(new Error(`${name}: ${messageOf(error)}`))))
288
331
 
289
- const clientsHere = async (): Promise<Client[]> => [...(await claude.session.surfaces())]
332
+ const surfacesHere = async (): Promise<Surface[]> => [...(await claude.session.surfaces())]
290
333
 
291
334
  const sessionOf = (read: () => SessionData, owned?: Owned) => {
292
335
  const id = () => read().id
293
336
  const isHere = () => owned === undefined && id() === current.id
294
- const scope = (): Scope => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task })
337
+ const scope = (): Scope => (owned === undefined ? { session: id(), isAgent: true } : { session: owned.session, task: owned.task, isAgent: true })
295
338
  const where = () => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task })
296
- const turnsOf = async (options: { readonly last?: number } = {}): Promise<Turn[]> => (await call<TurnData[]>(conversationMethods.turns, { ...where(), ...options })).map((turn) => turnOf(scope(), turn))
297
- const contextsOf = async (): Promise<Context[]> => (await call<ContextData[]>(conversationMethods.contexts, where())).map((context) => contextOf(scope(), context))
339
+ const turnsOf = async (options: { readonly last?: number } = {}): Promise<Turn[]> => (await call<TurnData[]>(conversationMethods.turns, { ...where(), ...options })).map((turn) => turnOf(where(), turn))
340
+ const contextsOf = async (): Promise<Context[]> => (await call<ContextData[]>(conversationMethods.contexts, where())).map((context) => contextOf(where(), context))
298
341
  const session = {
299
342
  get id() {
300
343
  return read().id
@@ -326,10 +369,9 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
326
369
  get forkedFrom() {
327
370
  return read().forkedFrom
328
371
  },
329
- search: (text: string, options: SearchOptions = {}) => recordPart(scope()).search(text, options),
330
- on: (event: SessionEvent, matcherOrListener: unknown, maybeListener?: unknown) => (recordPart(scope()).on as (...args: unknown[]) => Disposable)(event, matcherOrListener, maybeListener),
331
- before: (event: BeforeEvent, matcherOrListener: unknown, maybeListener?: unknown) => (recordPart(scope()).before as (...args: unknown[]) => Disposable)(event, matcherOrListener, maybeListener),
332
- goal: async () => goalOf(scope(), await call<GoalData>(conversationMethods.goal, where())),
372
+ search: (text: string, matcher?: Matcher<Message>) => recordPart(scope()).search(text, matcher),
373
+ ...eventMethods(scope, true),
374
+ goal: goalOf(where),
333
375
  context: async () => {
334
376
  const all = await contextsOf()
335
377
  const last = all.at(-1)
@@ -342,29 +384,27 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
342
384
  return last?.closedAt === undefined ? last : undefined
343
385
  },
344
386
  turns: turnsOf,
345
- messages: (options: { readonly after?: string; readonly last?: number } = {}) => call<Message[]>(conversationMethods.messages, { ...where(), ...options }),
387
+ messages: (options: { readonly after?: string; readonly last?: number } = {}) => messagesFrom(conversationMethods.messages, { ...where(), ...options }),
346
388
  usage: async (): Promise<Usage> => (isHere() ? usageOf(claude) : act<Usage>(id(), 'usage', null, owned)),
347
- clients: async (): Promise<Client[]> => (isHere() ? clientsHere() : act<Client[]>(id(), 'clients', null, owned)),
389
+ surfaces: async (): Promise<Surface[]> => (isHere() ? surfacesHere() : act<Surface[]>(id(), 'surfaces', null, owned)),
348
390
  async send(message: SessionSend) {
349
391
  if (message.text.trim() === '') throw new Error(`${name}: send needs text.`)
350
392
  if (isHere() && message.as !== Session) return sendHere(message)
351
393
  await act(id(), 'send', message, owned)
352
394
  },
395
+ }
396
+ if (owned !== undefined) return session as unknown as Sessions['current']
397
+ return Object.assign(session, {
353
398
  async startTask(task: TaskStartArgs): Promise<Task> {
354
- if (owned !== undefined) throw new Error(asked('startTask'))
355
399
  if (isHere()) return startHere(task)
356
400
  const started = await act<TaskData>(id(), 'startTask', task)
357
401
  return taskOf(id(), started)
358
402
  },
359
- async callTool(toolCall: ToolCallArgs): Promise<ToolResult> {
360
- if (owned !== undefined) throw new Error(asked('callTool'))
361
- return act<ToolResult>(id(), 'callTool', toolCall)
362
- },
363
- }
364
- return session as unknown as Sessions['current']
403
+ callTool: async (toolCall: ToolCallArgs): Promise<ToolResult> => (await withFiles(await act<ToolResult>(id(), 'callTool', toolCall))) as ToolResult,
404
+ }) as unknown as Sessions['current']
365
405
  }
366
406
 
367
- const ownerOf = (session: string, data: TaskData): Sessions['current'] => {
407
+ const ownerOf = (session: string, data: TaskData): TaskOwner => {
368
408
  const parent = session === current.id ? current : known.get(session)
369
409
  return sessionOf(
370
410
  () => ({
@@ -382,10 +422,114 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
382
422
  )
383
423
  }
384
424
 
385
- const currentSession = sessionOf(() => current)
386
- const every = recordPart({})
425
+ type ProcessListener = { readonly matcher: unknown; readonly listener: (payload: HookFields, time: ListenTime) => unknown; readonly options: ListenOptions }
426
+ const processListeners = new Map<string, Set<ProcessListener>>()
427
+
428
+ const raise = async (method: string, payload: HookFields): Promise<HookFields | undefined> => {
429
+ const isBefore = method.startsWith('before')
430
+ const heard = [...(processListeners.get(method) ?? [])].filter(({ matcher }) => payload['task'] === undefined && (matcher === undefined || matches(payload, matcher)))
431
+ if (!isBefore) {
432
+ await Promise.all(
433
+ heard.map(({ listener, options }) =>
434
+ withinTime(claude.clock, method, options, (time) => listener(payload, time)).catch((error: unknown) => {
435
+ if (!(error instanceof PassedTimeout)) report(`session:${method}`, `The ${method} listener failed`, error)
436
+ }),
437
+ ),
438
+ )
439
+ return undefined
440
+ }
441
+ const takes = answerKeys[method.slice('before'.length)] ?? []
442
+ let merged: HookFields | undefined
443
+ for (const { listener, options } of heard) {
444
+ try {
445
+ const answer = (await withinTime(claude.clock, method, options, (time) => listener(payload, time))) as HookFields | null | undefined
446
+ if (answer === undefined || answer === null) continue
447
+ const unread = Object.keys(answer).filter((key) => !takes.includes(key))
448
+ if (unread.length > 0) throw new Error(`it answered ${unread.join(' and ')}, which ${method} does not take. It takes ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
449
+ const notes = [merged?.['note'], answer['note']].filter((note): note is string => typeof note === 'string')
450
+ merged = { ...merged, ...answer, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
451
+ if (typeof answer['refuse'] === 'string') return merged
452
+ } catch (error) {
453
+ report(`session:${method}`, `The ${method} listener failed`, error)
454
+ if (options.onFailure === 'refuse' || method === 'beforePermissionRequest') return { refuse: `${name}: ${messageOf(error)}` }
455
+ }
456
+ }
457
+ return merged
458
+ }
459
+
460
+ const permissionCheck = (e: ToolCheck, below: ToolCheckVerdict): HookFields => ({ session: current.id, cwd: current.cwd, ...defined({ task: e.agentId, callId: e.tool_use_id, reason: below.reason, rule: below.rule, ceiling: e.ceiling === undefined ? undefined : verdictName(e.ceiling) }), name: e.tool, input: recordOf(e.input), decision: verdictName(below.decision) })
461
+
462
+ const commandRun = async (e: Args<'command.run'>): Promise<HookFields> => {
463
+ const source = (await claude.command.list()).find((command) => command.name === e.command)?.source
464
+ return { session: current.id, cwd: current.cwd, name: e.command, args: e.args, ...defined({ source }), from: senderOf(e.origin, false) }
465
+ }
466
+
467
+ const commandListeners = { beforeRun: new Set<ProcessListener>(), onRun: new Set<ProcessListener>() }
468
+ for (const [method, held] of Object.entries(commandListeners)) processListeners.set(method, held)
469
+ routeOn?.('command.run', async (e, next) => {
470
+ if (commandListeners.beforeRun.size === 0 && commandListeners.onRun.size === 0) return next(e)
471
+ const run = await commandRun(e)
472
+ const answer = commandListeners.beforeRun.size === 0 ? undefined : await raise('beforeRun', run)
473
+ if (typeof answer?.['refuse'] === 'string') return { text: answer['refuse'] }
474
+ const changes = answer !== undefined && isGranted('prompt') ? answer : undefined
475
+ if (answer !== undefined && changes === undefined) report('session:beforeRun', 'The beforeRun listener failed', new Error(`${name}: its answer changes a command, which needs the prompt permission.`))
476
+ if (typeof changes?.['result'] === 'string') return { text: changes['result'], ...(typeof changes['exitCode'] === 'number' ? { exitCode: changes['exitCode'] } : {}) }
477
+ const args = typeof changes?.['args'] === 'string' ? changes['args'] : e.args
478
+ const ran = await next(args === e.args ? e : { ...e, args })
479
+ void raise('onRun', { ...run, args })
480
+ return typeof changes?.['note'] === 'string' ? { ...ran, context: [...(ran.context ?? []), changes['note']] } : ran
481
+ })
482
+
483
+ const listenTo = (method: string, named: string) => {
484
+ if (hookOn === undefined || routeOn === undefined) throw new Error(`${name}: ${method} works only in a mod's own session.`)
485
+ const isBefore = method.startsWith('before')
486
+ if (named === 'PermissionCheck') {
487
+ return routeOn('tool.check', async (e: ToolCheck, next) => {
488
+ const below = (await next(e)) as ToolCheckVerdict
489
+ if (!isBefore) {
490
+ void raise(method, permissionCheck(e, below))
491
+ return below
492
+ }
493
+ const answer = await raise(method, permissionCheck(e, below))
494
+ if (answer === undefined || below.decision === 'deny') return below
495
+ if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
496
+ if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
497
+ return answer['allow'] === true ? { decision: 'allow' } : below
498
+ })
499
+ }
500
+ const event: { readonly on: string; readonly before?: string } = processEvents[named as ProcessName]
501
+ const hook = (isBefore ? event.before : undefined) ?? event.on
502
+ return hookOn(hook, async (input: unknown) => {
503
+ const payload = processPayloadOf(named as Exclude<ProcessName, 'PermissionCheck'>, input)
504
+ if (!isBefore) {
505
+ if (named !== 'SessionStart' || payload['source'] !== 'compact') void raise(method, payload)
506
+ return undefined
507
+ }
508
+ const answer = await raise(method, payload)
509
+ return answer === undefined ? undefined : hookAnswerOf(named, hook, answer)
510
+ })
511
+ }
512
+
513
+ const listen = (method: string, args: readonly unknown[]): Disposable => {
514
+ const entry = listenerOf(name, method, args) as ProcessListener
515
+ const held = processListeners.get(method) ?? new Set()
516
+ if (!processListeners.has(method)) {
517
+ listenTo(method, method.replace(/^(on|before)/, ''))
518
+ processListeners.set(method, held)
519
+ }
520
+ held.add(entry)
521
+ return { dispose: () => void held.delete(entry) }
522
+ }
523
+
524
+ const processMethods = (): ProcessOnMethods & ProcessBeforeMethods =>
525
+ Object.fromEntries(
526
+ Object.entries(processEvents).flatMap(([named, event]) => [`on${named}`, ...('before' in event ? [`before${named}`] : [])]).map((method) => [method, (...args: unknown[]) => listen(method, args)]),
527
+ ) as ProcessOnMethods & ProcessBeforeMethods
387
528
 
388
- const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (isGranted('conversation') ? combined(session, event, change, isInProject) : undefined)
529
+ const currentSession = Object.assign(sessionOf(() => current), processMethods())
530
+ const every = eventMethods(() => ({}), false)
531
+
532
+ const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (session === current.id || isGranted('conversation') ? combined(session, event, change, isInProject) : undefined)
389
533
 
390
534
  return {
391
535
  sessions: {
@@ -401,22 +545,25 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
401
545
  if (current.title === '') current = { ...current, title: data.title }
402
546
  return currentSession
403
547
  }),
404
- search: (text, options = {}) => call<Message[]>(conversationMethods.search, { text, ...optionsOf(options), ...(options.running === undefined ? {} : { running: options.running }), ...(options.since === undefined ? {} : { since: options.since }), ...(options.everyProject === undefined ? {} : { everyProject: options.everyProject }) }),
405
- on: every.on,
406
- before: every.before,
548
+ search: (text, matcher, options = {}) => searched({ text, ...searchOptionsOf(options) }, matcher),
549
+ ...every,
407
550
  },
408
551
  changed(session, change, isInProject) {
409
552
  remember(session, change)
410
553
  for (const key of isInProject ? [session, everySession] : [session]) {
411
- for (const { scope, event, matcher, listener } of listening.get(key) ?? []) {
554
+ for (const { scope, event, method, matcher, listener, options } of listening.get(key) ?? []) {
412
555
  if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session))) continue
413
- void Promise.resolve()
414
- .then(() => listener(payloadFor(session, change) as never))
415
- .catch((error: unknown) => report(`session:${event}`, `The ${event} listener failed`, error))
556
+ void payloadFor(session, change)
557
+ .then((payload) => withinTime(claude.clock, method, options, (time) => listener(payload as never, time)))
558
+ .catch((error: unknown) => {
559
+ if (!(error instanceof PassedTimeout)) report(`session:${method}`, `The ${method} listener failed`, error)
560
+ })
416
561
  }
417
562
  }
418
563
  },
419
564
  answerBefore,
565
+ raise,
566
+ listen,
420
567
  follow(next) {
421
568
  const moved = next.id !== current.id && current.id !== ''
422
569
  const previous = current.id
@@ -436,12 +583,141 @@ export function createSessionsClient({ name, claude, report, isGranted }: { read
436
583
  }
437
584
  }
438
585
 
439
- export function resultOf(where: Pick<Place, 'session' | 'context' | 'turn'>, answered: Awaited<ReturnType<Claude['tool']['call']>>): ToolResult {
440
- const base: Omit<ToolResult, 'text' | 'isError'> = { id: '', type: ToolResult, at: new Date().toISOString(), session: where.session, context: where.context, ...(where.turn === undefined ? {} : { turn: where.turn.prompt.id }), callId: '' }
441
- if (answered.deny !== undefined) return { ...base, text: answered.deny, isError: true }
442
- return { ...base, text: answered.text ?? JSON.stringify(answered.result), isError: answered.isError === true }
586
+ type HookFields = Readonly<Record<string, unknown>>
587
+
588
+ type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
589
+
590
+ type ToolCheckVerdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?: string; readonly rule?: string }
591
+
592
+ export type RouteOn = {
593
+ (event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>): Disposable
594
+ (event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>): Disposable
595
+ }
596
+
597
+ const verdictName = (decision: 'allow' | 'ask' | 'deny') => (decision === 'deny' ? 'refuse' : decision)
598
+
599
+ const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
600
+
601
+ const defined = (fields: HookFields): HookFields => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
602
+
603
+ const placementOf = (input: HookFields): Placement => defined({ session: input['session_id'], task: input['agent_id'], agentType: input['agent_type'], cwd: input['cwd'], permissionMode: input['permission_mode'] }) as Placement
604
+
605
+ const mcpServerOf = (server: unknown) => (server === undefined ? undefined : { name: String(recordOf(server)['name']), source: String(recordOf(server)['source']) })
606
+
607
+ const modelChangeOf = (input: HookFields) => ({
608
+ model: input['to_model'],
609
+ previous: input['from_model'],
610
+ requested: input['requested_model'] ?? undefined,
611
+ source: input['source'],
612
+ contextTokenCount: input['context_tokens'],
613
+ isCacheWarm: input['prompt_cache_warm'],
614
+ cacheTtl: input['cache_ttl'],
615
+ cacheWriteUsd: input['estimated_cache_write_usd'],
616
+ pricing: input['pricing'],
617
+ })
618
+
619
+ const answerKeys: Readonly<Record<string, readonly string[]>> = {
620
+ Run: ['args', 'note', 'result', 'exitCode', 'refuse'],
621
+ ProjectSetup: [],
622
+ SessionStart: ['note', 'title', 'reloadSkills', 'firstPrompt'],
623
+ ModelChange: ['ask', 'refuse'],
624
+ WorktreeCreate: ['result'],
625
+ Elicitation: ['refuse'],
626
+ ElicitationResponse: ['refuse'],
627
+ PermissionCheck: ['allow', 'ask', 'refuse'],
628
+ PermissionRequest: ['allow', 'input', 'updates', 'refuse', 'interrupt'],
629
+ PermissionDenial: ['retry'],
630
+ ToolBatch: ['note'],
631
+ TodoCreate: ['refuse'],
632
+ TodoComplete: ['refuse'],
633
+ ReplyDisplay: ['text'],
443
634
  }
444
635
 
445
- function optionsOf(options: SearchOptions): Readonly<Record<string, unknown>> {
446
- return { ...(options.limit === undefined ? {} : { limit: options.limit }), ...(options.before === undefined ? {} : { before: { session: options.before.session, id: options.before.id } }) }
636
+ const todoOf = (input: HookFields) => ({ id: input['task_id'], subject: input['task_subject'], description: input['task_description'], teammate: input['teammate_name'] })
637
+
638
+ const processFields: { readonly [Name in Exclude<keyof typeof processEvents, 'PermissionCheck'>]: (input: HookFields) => HookFields } = {
639
+ ProjectSetup: (input) => ({ trigger: input['trigger'] }),
640
+ SessionStart: (input) => ({
641
+ source: input['source'],
642
+ model: input['model'],
643
+ title: input['session_title'] || undefined,
644
+ idleSeconds: input['seconds_since_last_response'],
645
+ contextTokenCount: input['context_tokens'],
646
+ isCacheWarm: input['prompt_cache_likely_expired'] === undefined ? undefined : input['prompt_cache_likely_expired'] !== true,
647
+ cacheWriteUsd: input['estimated_cache_write_usd'],
648
+ }),
649
+ SessionEnd: (input) => ({ reason: input['reason'] === 'prompt_input_exit' ? 'exit' : input['reason'] }),
650
+ ModelChange: modelChangeOf,
651
+ CwdChange: (input) => ({ cwd: input['new_cwd'] ?? input['cwd'], previous: input['old_cwd'] }),
652
+ DirectoryAdd: (input) => ({ directory: input['directory'], source: input['source'] === 'slash_command' ? 'command' : 'sdk' }),
653
+ WorktreeCreate: (input) => ({ name: input['name'] }),
654
+ WorktreeRemove: (input) => ({ path: input['worktree_path'] }),
655
+ Notification: (input) => ({ text: input['message'], title: input['title'], type: input['notification_type'] }),
656
+ Elicitation: (input) => ({ id: input['elicitation_id'], server: input['mcp_server_name'], text: input['message'], mode: input['mode'], url: input['url'], schema: input['requested_schema'] }),
657
+ ElicitationResponse: (input) => ({ id: input['elicitation_id'], server: input['mcp_server_name'], mode: input['mode'], action: input['action'], content: input['content'] }),
658
+ PermissionRequest: (input) => ({ name: input['tool_name'], input: recordOf(input['tool_input']), suggestions: input['permission_suggestions'] ?? [], mcpServer: mcpServerOf(input['mcp_server']) }),
659
+ PermissionDenial: (input) => ({ name: input['tool_name'], input: recordOf(input['tool_input']), callId: input['tool_use_id'], reason: input['reason'], mcpServer: mcpServerOf(input['mcp_server']) }),
660
+ ToolBatch: (input) => ({
661
+ calls: (Array.isArray(input['tool_calls']) ? input['tool_calls'] : []).map((call: unknown) => {
662
+ const fields = recordOf(call)
663
+ return defined({ callId: fields['tool_use_id'], name: fields['tool_name'], input: recordOf(fields['tool_input']), output: fields['tool_response'] })
664
+ }),
665
+ }),
666
+ TodoCreate: todoOf,
667
+ TodoComplete: todoOf,
668
+ ReplyDisplay: (input) => ({ replyId: input['message_id'], index: input['index'], isLast: input['final'], text: input['delta'] }),
669
+ }
670
+
671
+ export async function spawnTask(claude: Pick<Claude, 'agent'>, task: TaskStartArgs): Promise<TaskData> {
672
+ if (task.title.trim() === '') throw new Error('a task needs a title.')
673
+ const started = await claude.agent.spawn({
674
+ prompt: task.prompt,
675
+ description: task.title,
676
+ ...(task.name === undefined ? {} : { name: task.name }),
677
+ ...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
678
+ ...(task.model === undefined ? {} : { model: task.model }),
679
+ ...(task.cwd === undefined ? {} : { cwd: task.cwd }),
680
+ })
681
+ if (started.deny !== undefined) throw new Error(`Claude Code refused the task: ${started.deny}`)
682
+ if (started.agentId === undefined) throw new Error('Claude Code answered without starting a task.')
683
+ return { id: started.agentId, title: task.title, agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() }
684
+ }
685
+
686
+ export function processPayloadOf(named: Exclude<keyof typeof processEvents, 'PermissionCheck'>, input: unknown): HookFields {
687
+ const fields = recordOf(input)
688
+ return { ...placementOf(fields), ...defined(processFields[named](fields)) }
689
+ }
690
+
691
+ export function hookAnswerOf(named: string, hook: string, answer: HookFields): HookAnswer | undefined {
692
+ const output = (fields: HookFields): HookAnswer => ({ hookSpecificOutput: { hookEventName: hook as Hook, ...defined(fields) } })
693
+ const refuse = typeof answer['refuse'] === 'string' ? answer['refuse'] : undefined
694
+ if (named === 'SessionStart') return output({ additionalContext: answer['note'], sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'], initialUserMessage: answer['firstPrompt'] })
695
+ if (named === 'ModelChange') {
696
+ if (refuse !== undefined) return output({ permissionDecision: 'deny', permissionDecisionReason: refuse })
697
+ return typeof answer['ask'] === 'string' ? output({ permissionDecision: 'ask', permissionDecisionReason: answer['ask'] }) : undefined
698
+ }
699
+ if (named === 'WorktreeCreate') return output({ worktreePath: answer['result'] })
700
+ if (named === 'PermissionRequest') {
701
+ if (refuse !== undefined) return output({ decision: defined({ behavior: 'deny', message: refuse, interrupt: answer['interrupt'] }) })
702
+ return answer['allow'] === true ? output({ decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) }) : undefined
703
+ }
704
+ if (named === 'PermissionDenial') return output({ retry: answer['retry'] })
705
+ if (named === 'ToolBatch') return output({ additionalContext: answer['note'] })
706
+ if (named === 'ReplyDisplay') return output({ displayContent: answer['text'] })
707
+ return refuse === undefined ? undefined : { decision: 'block', reason: refuse }
708
+ }
709
+
710
+ export function resultOf(where: Pick<Place, 'session' | 'context' | 'turn'>, call: ToolCallArgs & { readonly cwd: string }, answered: Awaited<ReturnType<Claude['tool']['call']>>): ToolResult {
711
+ const base: Omit<ToolResult, 'output' | 'isError'> = { id: '', type: ToolResult, at: new Date().toISOString(), session: where.session, context: where.context, ...(where.turn === undefined ? {} : { turn: where.turn.prompt.id }), callId: '', name: call.name, input: call.input, cwd: call.cwd, files: noFiles }
712
+ if (answered.deny !== undefined) return { ...base, output: answered.deny, isError: true }
713
+ return { ...base, output: answered.result ?? answered.text, isError: answered.isError === true }
714
+ }
715
+
716
+ function searchOptionsOf({ running, since, everyProject, before }: SearchOptions): Readonly<Record<string, unknown>> {
717
+ return {
718
+ ...(running === undefined ? {} : { running }),
719
+ ...(since === undefined ? {} : { since }),
720
+ ...(everyProject === undefined ? {} : { everyProject }),
721
+ ...(before === undefined ? {} : { before: { session: before.session, id: before.id } }),
722
+ }
447
723
  }