@cmodjs/core 1.6.0 → 2.0.1

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