@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.
- package/docs/commands.md +5 -5
- package/docs/index.md +12 -11
- package/docs/jobs.md +12 -16
- package/docs/mod.md +117 -39
- package/docs/permissions.md +17 -21
- package/docs/session.md +171 -82
- package/docs/state.md +5 -5
- package/docs/testing.md +57 -41
- package/docs/ui.md +9 -9
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +1 -1
- package/jobs/prompt.d.ts +1 -3
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +2 -21
- package/jobs/slash-command.d.ts +1 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +1 -1
- package/jobs/status-line.d.ts +0 -1
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +3 -4
- package/mod.d.ts +53 -105
- package/mod.d.ts.map +1 -1
- package/mod.js +18 -80
- package/package.json +1 -1
- package/records.d.ts +3 -1
- package/records.d.ts.map +1 -1
- package/records.js +14 -5
- package/register.d.ts.map +1 -1
- package/register.js +3 -1
- package/runtime/before.d.ts +5 -1
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +81 -39
- package/runtime/claude.d.ts +6 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +45 -26
- package/runtime/deadline.d.ts +17 -0
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +34 -0
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +12 -0
- package/runtime/grants.d.ts +1 -1
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +19 -12
- package/runtime/hooks.d.ts +28 -11
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +20 -123
- package/runtime/lifecycle.d.ts +2 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +67 -50
- package/runtime/sessions.d.ts +31 -5
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +342 -81
- package/runtime/tool-calls.d.ts +0 -1
- package/runtime/tool-calls.d.ts.map +1 -1
- package/runtime/tool-calls.js +0 -19
- package/session/context/turn/message/instruction-file.d.ts +9 -0
- package/session/context/turn/message/instruction-file.d.ts.map +1 -0
- package/session/context/turn/message/instruction-file.js +4 -0
- package/session/context/turn/message/message.d.ts +5 -4
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +7 -6
- package/session/context/turn/message/sender.d.ts +2 -1
- package/session/context/turn/message/sender.d.ts.map +1 -1
- package/session/context/turn/message/sender.js +4 -1
- package/session/context/turn/message/tool-call.d.ts +5 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -1
- package/session/context/turn/message/tool-result.d.ts +6 -1
- package/session/context/turn/message/tool-result.d.ts.map +1 -1
- package/session/context/turn/turn.d.ts +7 -0
- package/session/context/turn/turn.d.ts.map +1 -1
- package/session/conversation.d.ts +10 -3
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +14 -5
- package/session/goal/goal.d.ts +1 -1
- package/session/goal/goal.d.ts.map +1 -1
- package/session/goal/task.d.ts +2 -2
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +361 -26
- package/session/session.d.ts.map +1 -1
- package/session/session.js +40 -0
- package/src/jobs/permissions/decide-permission.ts +2 -1
- package/src/jobs/prompt.ts +3 -20
- package/src/jobs/slash-command.ts +2 -2
- package/src/jobs/status-line.ts +4 -7
- package/src/mod.ts +52 -147
- package/src/records.ts +15 -5
- package/src/register.ts +3 -1
- package/src/runtime/before.ts +90 -37
- package/src/runtime/claude.ts +5 -1
- package/src/runtime/conversations.ts +41 -25
- package/src/runtime/deadline.ts +38 -0
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/grants.ts +15 -10
- package/src/runtime/hooks.ts +48 -116
- package/src/runtime/lifecycle.ts +66 -51
- package/src/runtime/sessions.ts +366 -90
- package/src/runtime/tool-calls.ts +0 -19
- package/src/session/context/turn/message/instruction-file.ts +13 -0
- package/src/session/context/turn/message/message.ts +11 -9
- package/src/session/context/turn/message/sender.ts +5 -2
- package/src/session/context/turn/message/tool-call.ts +3 -0
- package/src/session/context/turn/message/tool-result.ts +6 -1
- package/src/session/context/turn/turn.ts +4 -0
- package/src/session/conversation.ts +21 -8
- package/src/session/goal/goal.ts +1 -1
- package/src/session/goal/task.ts +2 -2
- package/src/session/session.ts +227 -24
- package/src/testing/claude-code.ts +23 -0
- package/src/testing/fake-claude.ts +5 -1
- package/src/testing/fake-sessions.ts +38 -24
- package/src/testing.ts +84 -16
- package/testing/claude-code.d.ts +12 -0
- package/testing/claude-code.d.ts.map +1 -0
- package/testing/claude-code.js +10 -0
- package/testing/fake-claude.d.ts +4 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +4 -1
- package/testing/fake-sessions.d.ts +8 -6
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +35 -20
- package/testing.d.ts +38 -9
- package/testing.d.ts.map +1 -1
- package/testing.js +54 -5
- package/docs/hooks.md +0 -259
- package/session/context/turn/message/document.d.ts +0 -7
- package/session/context/turn/message/document.d.ts.map +0 -1
- package/session/context/turn/message/document.js +0 -1
- package/src/session/context/turn/message/document.ts +0 -8
package/src/runtime/sessions.ts
CHANGED
|
@@ -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
|
|
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
|
|
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' | '
|
|
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',
|
|
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
|
|
51
|
+
export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart ? 'tasks' : 'prompt')
|
|
47
52
|
|
|
48
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
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(`
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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: () =>
|
|
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: () =>
|
|
287
|
+
messages: () => messagesFrom(conversationMethods.messages, { session, task: data.id }),
|
|
242
288
|
})
|
|
243
289
|
|
|
244
|
-
const goalOf = (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
297
|
-
const contextsOf = async (): Promise<Context[]> => (await call<ContextData[]>(conversationMethods.contexts, where())).map((context) => contextOf(
|
|
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,
|
|
330
|
-
|
|
331
|
-
|
|
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 } = {}) =>
|
|
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
|
-
|
|
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
|
|
360
|
-
|
|
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):
|
|
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
|
-
|
|
386
|
-
const
|
|
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
|
|
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 = {}) =>
|
|
405
|
-
|
|
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
|
|
414
|
-
.then(() =>
|
|
415
|
-
.catch((error: unknown) =>
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
}
|