@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.
- package/docs/commands.md +7 -7
- package/docs/dependencies.md +1 -1
- package/docs/index.md +12 -11
- package/docs/jobs.md +12 -16
- package/docs/mod.md +111 -36
- package/docs/permissions.md +14 -18
- 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 +1 -1
- package/records.js +1 -1
- package/register.d.ts.map +1 -1
- package/register.js +8 -5
- package/runtime/before.d.ts +11 -4
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +131 -67
- 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 +82 -38
- 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.map +1 -1
- package/runtime/grants.js +13 -8
- package/runtime/hooks.d.ts +15 -11
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +8 -165
- package/runtime/lifecycle.d.ts +4 -2
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +96 -62
- package/runtime/permission-check.d.ts +15 -0
- package/runtime/permission-check.d.ts.map +1 -0
- package/runtime/permission-check.js +37 -0
- package/runtime/sessions.d.ts +35 -5
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +304 -82
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +14 -11
- 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 +6 -4
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +12 -8
- 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 +1 -1
- package/src/register.ts +8 -5
- package/src/runtime/before.ts +140 -66
- package/src/runtime/claude.ts +5 -1
- package/src/runtime/conversations.ts +75 -37
- package/src/runtime/deadline.ts +38 -0
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/grants.ts +11 -7
- package/src/runtime/hooks.ts +24 -163
- package/src/runtime/lifecycle.ts +110 -77
- package/src/runtime/permission-check.ts +47 -0
- package/src/runtime/sessions.ts +334 -91
- package/src/runtime/state.ts +15 -8
- 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 +17 -11
- 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 +88 -19
- 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 +58 -8
- 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,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
|
|
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 { 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' | '
|
|
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',
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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: () =>
|
|
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: () =>
|
|
293
|
+
messages: () => messagesFrom(conversationMethods.messages, { session, task: data.id }),
|
|
242
294
|
})
|
|
243
295
|
|
|
244
|
-
const goalOf = (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
297
|
-
const contextsOf = async (): Promise<Context[]> => (await call<ContextData[]>(conversationMethods.contexts, where())).map((context) => contextOf(
|
|
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,
|
|
330
|
-
|
|
331
|
-
|
|
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 } = {}) =>
|
|
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
|
-
|
|
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
|
|
360
|
-
|
|
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):
|
|
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
|
-
|
|
386
|
-
const
|
|
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
|
|
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 = {}) =>
|
|
405
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
.
|
|
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
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
446
|
-
return {
|
|
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
|
}
|