@cmodjs/core 1.6.0 → 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 +4 -4
- 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 +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.map +1 -1
- package/runtime/grants.js +13 -8
- 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 +66 -49
- 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 +1 -1
- 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 +11 -7
- package/src/runtime/hooks.ts +48 -116
- package/src/runtime/lifecycle.ts +65 -50
- 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
|
@@ -3,8 +3,8 @@ import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
|
|
|
3
3
|
import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
|
|
4
4
|
import { Note } from '../session/context/turn/message/note.js'
|
|
5
5
|
import { Person } from '../session/context/turn/message/sender.js'
|
|
6
|
-
import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
|
|
7
|
-
import { Session, UsageChange, type BeforeEvent, type
|
|
6
|
+
import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
|
|
7
|
+
import { Session, UsageChange, type BeforeEvent, type Surface, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
|
|
8
8
|
import { GoalChange } from '../session/goal/goal.js'
|
|
9
9
|
import { receivedMatcher } from '../utils/matcher.js'
|
|
10
10
|
import { ifMissing } from '../utils/paths.js'
|
|
@@ -14,7 +14,7 @@ import type { Claude } from './claude.js'
|
|
|
14
14
|
import { cmodPluginName } from './dependencies.js'
|
|
15
15
|
import { readLines, runCmod } from './programs.js'
|
|
16
16
|
import type { Router } from './router.js'
|
|
17
|
-
import { actPermission,
|
|
17
|
+
import { actPermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
|
|
18
18
|
|
|
19
19
|
type Request = Readonly<Record<string, unknown>>
|
|
20
20
|
|
|
@@ -64,6 +64,21 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
64
64
|
const liveIds = new Set<string>()
|
|
65
65
|
const waitingRows = new Map<string, Row[]>()
|
|
66
66
|
let follower: { readonly sessions: string; stop(): void } | undefined
|
|
67
|
+
const engineTurns = new Map<string, string>()
|
|
68
|
+
router.add('turn.start', async (e, next) => {
|
|
69
|
+
const agent = (e as typeof e & { readonly agentId?: string }).agentId ?? ''
|
|
70
|
+
engineTurns.set(agent, e.turnId)
|
|
71
|
+
return next(e)
|
|
72
|
+
})
|
|
73
|
+
const endTurn = (ending: TurnEnding, task?: string) => <Below extends { readonly block?: string }>(below: Below): Below => {
|
|
74
|
+
if (below.block !== undefined) return below
|
|
75
|
+
engineTurns.delete(task ?? '')
|
|
76
|
+
dispatch(session, conversation.endTurn(new Date().toISOString(), ending, task))
|
|
77
|
+
return below
|
|
78
|
+
}
|
|
79
|
+
router.add('classic.Stop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) })(await next(e)))
|
|
80
|
+
router.add('classic.StopFailure', async (e, next) => endTurn({ reason: 'error', failure: { type: String(e.error), text: e.error_details ?? e.last_assistant_message ?? String(e.error) } })(await next(e)))
|
|
81
|
+
router.add('classic.SubagentStop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) }, e.agent_id)(await next(e)))
|
|
67
82
|
|
|
68
83
|
const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], session === '' ? await claude.session.id() : session)) as Value
|
|
69
84
|
|
|
@@ -94,6 +109,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
const restartFollower = async () => {
|
|
112
|
+
if (session === '') return
|
|
97
113
|
const wantsEvery = [...heard.values()].some((sessions) => (sessions.get(everySession) ?? []).length > 0)
|
|
98
114
|
const named = [...heard.values()].flatMap((sessions) => [...sessions].filter(([id, list]) => id !== session && id !== everySession && list.length > 0).map(([id]) => id))
|
|
99
115
|
const others = [...new Set(named)].sort()
|
|
@@ -189,8 +205,8 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
189
205
|
}
|
|
190
206
|
|
|
191
207
|
const place = (): Place => {
|
|
192
|
-
const { context, contextOpenedAt, turn, tasks, taskTurns } = conversation.state
|
|
193
|
-
return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns }
|
|
208
|
+
const { context, contextOpenedAt, turn, tasks, taskTurns, openCalls } = conversation.state
|
|
209
|
+
return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns, ...(openCalls === undefined ? {} : { openCalls }) }
|
|
194
210
|
}
|
|
195
211
|
|
|
196
212
|
const deciders = (event: string): Watcher[] => {
|
|
@@ -199,7 +215,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
199
215
|
}
|
|
200
216
|
|
|
201
217
|
const askHere = async (mod: string, event: BeforeEvent, change: SessionChange, from = session, isInProject = true) => {
|
|
202
|
-
|
|
218
|
+
if (from !== session && !(await isGranted(mod, 'conversation'))) throw new Error(`${mod} needs the conversation permission to change what happens in another session. Turn it on in /mods ${mod}.`)
|
|
203
219
|
return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
|
|
204
220
|
}
|
|
205
221
|
|
|
@@ -360,9 +376,10 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
360
376
|
const perform = async (caller: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
|
|
361
377
|
await checkAct(caller, action, input)
|
|
362
378
|
await starting
|
|
379
|
+
if (action === 'abort') return abortTurn(input, task)
|
|
363
380
|
if (task !== undefined) return performInTask(action, input, task)
|
|
364
381
|
if (action === 'usage') return usageOf(claude)
|
|
365
|
-
if (action === '
|
|
382
|
+
if (action === 'surfaces') return [...(await claude.session.surfaces())] satisfies Surface[]
|
|
366
383
|
if (action === 'send') {
|
|
367
384
|
const { type, text, as } = input as SessionSend
|
|
368
385
|
if (as === Session) throw new Error(`session ${session} cannot send as itself to itself.`)
|
|
@@ -371,26 +388,25 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
371
388
|
if ('drop' in delivered && typeof delivered.drop === 'string') throw new Error(`Claude Code dropped the prompt: ${delivered.drop}`)
|
|
372
389
|
return null
|
|
373
390
|
}
|
|
374
|
-
if (action === 'startTask')
|
|
375
|
-
const args = input as TaskStartArgs
|
|
376
|
-
const started = await claude.agent.spawn({
|
|
377
|
-
prompt: args.prompt,
|
|
378
|
-
...(args.name === undefined ? {} : { description: args.name, name: args.name }),
|
|
379
|
-
...(args.agentType === undefined ? {} : { subagentType: args.agentType }),
|
|
380
|
-
...(args.model === undefined ? {} : { model: args.model }),
|
|
381
|
-
...(args.cwd === undefined ? {} : { cwd: args.cwd }),
|
|
382
|
-
})
|
|
383
|
-
if (started.deny !== undefined) throw new Error(`Claude Code refused the task: ${started.deny}`)
|
|
384
|
-
return { id: started.agentId ?? '', title: args.name ?? '', agentType: args.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() } satisfies TaskData
|
|
385
|
-
}
|
|
391
|
+
if (action === 'startTask') return spawnTask(claude, input as TaskStartArgs)
|
|
386
392
|
const { name, input: toolInput } = input as ToolCallArgs
|
|
387
|
-
return resultOf(place(), await claude.tool.call({ ...toolInput, tool: name } as never))
|
|
393
|
+
return resultOf(place(), { name, input: toolInput, cwd: await claude.session.cwd() }, await claude.tool.call({ ...toolInput, tool: name } as never))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const abortTurn = async (input: unknown, task: string | undefined) => {
|
|
397
|
+
const turnId = engineTurns.get(task ?? '')
|
|
398
|
+
if (turnId === undefined) throw new Error(`no turn is running in ${task === undefined ? `session ${session}` : `task ${task}`}.`)
|
|
399
|
+
const asked = isObject(input) ? input['turn'] : undefined
|
|
400
|
+
const open = task === undefined ? conversation.state.turn : conversation.state.taskTurns[task]
|
|
401
|
+
if (open?.prompt.id !== asked) throw new Error(`turn ${String(asked)} has ended, so there is nothing to abort.`)
|
|
402
|
+
await claude.turn.abort({ turnId })
|
|
403
|
+
return null
|
|
388
404
|
}
|
|
389
405
|
|
|
390
406
|
const performInTask = async (action: Act, input: unknown, task: string): Promise<unknown> => {
|
|
391
407
|
const running = conversation.state.tasks[task]
|
|
392
408
|
if (action === 'usage') return json(['history', 'usage', session, '--task', task])
|
|
393
|
-
if (action === '
|
|
409
|
+
if (action === 'surfaces') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
|
|
394
410
|
if (action !== 'send') throw new Error(`cmod: ${action} runs only on a session's own main loop.`)
|
|
395
411
|
if (running === undefined || running.closedAt !== undefined) throw ended(task)
|
|
396
412
|
const sent = await claude.session.send({ to: { agentId: task }, text: (input as SessionSend).text })
|
|
@@ -402,7 +418,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
402
418
|
await checkAct(caller, action, input)
|
|
403
419
|
const isLive = await isRunning(target)
|
|
404
420
|
if (!isLive && action === 'usage') return json(['history', 'usage', target, ...(task === undefined ? [] : ['--task', task])])
|
|
405
|
-
if (!isLive && action === '
|
|
421
|
+
if (!isLive && action === 'surfaces') return []
|
|
406
422
|
if (!isLive) throw ended(task ?? target)
|
|
407
423
|
if (action === 'send' && task === undefined && (input as SessionSend).as === Session) {
|
|
408
424
|
const sent = await claude.session.send({ to: { sessionId: target }, text: (input as SessionSend).text })
|
|
@@ -452,8 +468,8 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
452
468
|
},
|
|
453
469
|
async [conversationMethods.search](request) {
|
|
454
470
|
const before = isObject(request['before']) ? request['before'] : undefined
|
|
455
|
-
const record = [...textArg('--session', request['session']), ...textArg('--task', request['task']), ...textArg('--turn', request['turn']), ...countArg('--context', request['context']), ...(request['
|
|
456
|
-
return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...record, ...(request['running'] === true ? ['--running'] : []), ...
|
|
471
|
+
const record = [...textArg('--session', request['session']), ...textArg('--task', request['task']), ...textArg('--turn', request['turn']), ...countArg('--context', request['context']), ...(request['isAgent'] === true ? ['--agent'] : [])]
|
|
472
|
+
return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...record, ...(request['running'] === true ? ['--running'] : []), ...(before === undefined ? [] : ['--before', `${String(before['session'])}:${String(before['id'])}`])])
|
|
457
473
|
},
|
|
458
474
|
async [conversationMethods.messages](request) {
|
|
459
475
|
const id = String(request['session'])
|
|
@@ -556,7 +572,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
556
572
|
const method = methods[e.method]
|
|
557
573
|
if (method === undefined) return undefined
|
|
558
574
|
const input = isObject(e.input) ? e.input : {}
|
|
559
|
-
if (e.method !== conversationMethods.act && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
|
|
575
|
+
if (e.method !== conversationMethods.act && input['session'] !== session && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
|
|
560
576
|
try {
|
|
561
577
|
return { value: await method(input, e.caller) }
|
|
562
578
|
} catch (error) {
|
package/src/runtime/deadline.ts
CHANGED
|
@@ -6,6 +6,44 @@ export const longestMs = 600_000
|
|
|
6
6
|
|
|
7
7
|
export const toolDeadline: Deadline = { ms: 30000, job: 'tool' }
|
|
8
8
|
|
|
9
|
+
export type ListenOptions = { readonly timeoutMs?: number; readonly onFailure?: 'pass' | 'refuse' }
|
|
10
|
+
|
|
11
|
+
export type ListenTime = { readonly deadline: number; readonly signal: AbortSignal }
|
|
12
|
+
|
|
13
|
+
export const listenerMs = 10_000
|
|
14
|
+
|
|
15
|
+
export class PassedTimeout extends Error {}
|
|
16
|
+
|
|
17
|
+
const timers = globalThis as unknown as { setTimeout(run: () => void, ms: number): unknown; clearTimeout(timer: unknown): void }
|
|
18
|
+
|
|
19
|
+
export function listenerOf(name: string, method: string, args: readonly unknown[]): { readonly matcher: unknown; readonly listener: (payload: never, time: ListenTime) => unknown; readonly options: ListenOptions } {
|
|
20
|
+
const hasMatcher = typeof args[1] === 'function'
|
|
21
|
+
const listener = (hasMatcher ? args[1] : args[0]) as (payload: never, time: ListenTime) => unknown
|
|
22
|
+
const options = ((hasMatcher ? args[2] : args[1]) ?? {}) as ListenOptions
|
|
23
|
+
if (typeof listener !== 'function') throw new Error(`${name}: ${method} takes a listener, or a matcher and then a listener.`)
|
|
24
|
+
const { timeoutMs, onFailure } = options as { readonly timeoutMs?: unknown; readonly onFailure?: unknown }
|
|
25
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > longestMs)) throw new Error(`${name}: ${method}'s timeoutMs is ${String(timeoutMs)}. Give a whole number of milliseconds above 0 and at most ${longestMs}.`)
|
|
26
|
+
if (onFailure !== undefined && onFailure !== 'pass' && onFailure !== 'refuse') throw new Error(`${name}: ${method}'s onFailure is ${String(onFailure)}. Give 'pass' or 'refuse'.`)
|
|
27
|
+
return { matcher: hasMatcher ? args[0] : undefined, listener, options }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function withinTime<Value>(clock: Pick<Claude['clock'], 'now'>, method: string, { timeoutMs = listenerMs }: ListenOptions, run: (time: ListenTime) => Value | Promise<Value>): Promise<Value> {
|
|
31
|
+
const controller = new AbortController()
|
|
32
|
+
const deadline = (await clock.now()) + timeoutMs
|
|
33
|
+
let timer: unknown
|
|
34
|
+
const passed = new Promise<never>((_resolve, reject) => {
|
|
35
|
+
timer = timers.setTimeout(() => {
|
|
36
|
+
controller.abort()
|
|
37
|
+
reject(new PassedTimeout(`${method} passed its ${timeoutMs / 1000} s timeout`))
|
|
38
|
+
}, timeoutMs)
|
|
39
|
+
})
|
|
40
|
+
try {
|
|
41
|
+
return await Promise.race([Promise.resolve().then(() => run({ deadline, signal: controller.signal })), passed])
|
|
42
|
+
} finally {
|
|
43
|
+
timers.clearTimeout(timer)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
9
47
|
export function beforeDeadline<Value>(claude: Claude, { ms, job }: Deadline, call: string, task: Promise<Value>): Promise<Value> {
|
|
10
48
|
return new Promise<Value>((resolve, reject) => {
|
|
11
49
|
const passed = job === undefined ? `its ${ms / 1000} s deadline` : `the ${ms / 1000} s deadline of ${job}`
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Args, CmodDependencies, EventResult, Frozen } from 'claude-code'
|
|
2
2
|
import { messageOf } from '../utils/text.js'
|
|
3
3
|
import type { Claude } from './claude.js'
|
|
4
|
+
import type { Router } from './router.js'
|
|
4
5
|
|
|
5
6
|
export const cmodPluginName = 'cmod'
|
|
6
7
|
|
|
@@ -33,6 +34,17 @@ export function withBareRefusals(claude: Claude): Claude {
|
|
|
33
34
|
return { ...claude, cmod: { call } }
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export function answeringOwnCalls(claude: Claude, router: Router): Claude {
|
|
38
|
+
const name = claude.plugin.name
|
|
39
|
+
const call: Claude['cmod']['call'] = async (input) => {
|
|
40
|
+
if (input.to !== name) return claude.cmod.call(input)
|
|
41
|
+
const answer = await router.dispatch('cmod.call', { ...input, caller: name } as Frozen<Args<'cmod.call'>>, async () => ({ deny: `${name} has no method ${input.method}.` }))
|
|
42
|
+
if (answer.deny !== undefined) throw new Error(answer.deny)
|
|
43
|
+
return answer.value
|
|
44
|
+
}
|
|
45
|
+
return { ...claude, cmod: { call } }
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export function notInstalled(name: string): string {
|
|
37
49
|
return `${name} is not installed. Add it with /plugin.`
|
|
38
50
|
}
|
package/src/runtime/grants.ts
CHANGED
|
@@ -90,10 +90,6 @@ export function checkingGrants(claude: Claude, grants: Grants): Claude {
|
|
|
90
90
|
},
|
|
91
91
|
session: {
|
|
92
92
|
...claude.session,
|
|
93
|
-
messages: (async (...args: Parameters<Claude['session']['messages']>) => {
|
|
94
|
-
await gate('session.messages', 'conversation')
|
|
95
|
-
return claude.session.messages(...args)
|
|
96
|
-
}) as Claude['session']['messages'],
|
|
97
93
|
append: async (args: Parameters<Claude['session']['append']>[0]) => {
|
|
98
94
|
await gate('send', 'prompt')
|
|
99
95
|
return claude.session.append(args)
|
|
@@ -109,12 +105,22 @@ export function checkingGrants(claude: Claude, grants: Grants): Claude {
|
|
|
109
105
|
return claude.prompt.submit(args)
|
|
110
106
|
},
|
|
111
107
|
},
|
|
108
|
+
command: {
|
|
109
|
+
...claude.command,
|
|
110
|
+
run: async (args: Parameters<Claude['command']['run']>[0]) => {
|
|
111
|
+
await gate(`commands.run(${args.command})`, 'prompt')
|
|
112
|
+
return claude.command.run(args)
|
|
113
|
+
},
|
|
114
|
+
},
|
|
112
115
|
cmod: {
|
|
113
116
|
async call(args) {
|
|
114
117
|
const named = args.to === cmodPluginName ? conversationCalls[args.method] : undefined
|
|
115
118
|
const given = args.method === conversationMethods.act && isObject(args.input) ? String(args.input['action']) : undefined
|
|
116
119
|
const action = given !== undefined && isAct(given) ? given : undefined
|
|
117
|
-
|
|
120
|
+
const isThisSession = isObject(args.input) && args.input['session'] === (await claude.session.id())
|
|
121
|
+
const isDropping = (args.method === conversationMethods.subscribe || args.method === conversationMethods.watchBefore) && isObject(args.input) && Array.isArray(args.input['subscriptions']) && args.input['subscriptions'].length === 0
|
|
122
|
+
if (named !== undefined && action !== undefined) await gate(action, actPermission(action, isObject(args.input) ? args.input['input'] : undefined))
|
|
123
|
+
else if (named !== undefined && !isThisSession && !isDropping) await gate(named, 'conversation')
|
|
118
124
|
return claude.cmod.call(args)
|
|
119
125
|
},
|
|
120
126
|
},
|
|
@@ -211,10 +217,8 @@ type Fields = Record<string, unknown>
|
|
|
211
217
|
const classicParts: Readonly<Record<string, string>> = {
|
|
212
218
|
additionalContext: 'prompt',
|
|
213
219
|
initialUserMessage: 'prompt',
|
|
214
|
-
suppressOriginalPrompt: 'prompt',
|
|
215
220
|
updatedToolOutput: 'prompt',
|
|
216
221
|
updatedMCPToolOutput: 'prompt',
|
|
217
|
-
updatedInput: 'tools',
|
|
218
222
|
retry: 'approve',
|
|
219
223
|
}
|
|
220
224
|
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -1,17 +1,38 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { Hook, HookAnswer, HookInput, HookListener } from '../mod.js'
|
|
1
|
+
import type { ClassicHookEvent, ClassicHookInputs, PermissionRequestDecision } from 'claude-code'
|
|
3
2
|
import { configFolders } from '../records.js'
|
|
4
3
|
import type { Claude } from './claude.js'
|
|
5
4
|
import type { RoutedHook } from './router.js'
|
|
6
5
|
import type { Failure } from './toaster.js'
|
|
7
|
-
import {
|
|
8
|
-
import { callEffects, inputOf, type CallBase, type FileAccess } from '../utils/call-effects.js'
|
|
6
|
+
import { callEffects, type CallBase, type FileAccess } from '../utils/call-effects.js'
|
|
9
7
|
import { dynamicPattern } from '../utils/parse-shell.js'
|
|
10
8
|
import { isMissing } from '../utils/paths.js'
|
|
11
9
|
import { messageOf } from '../utils/text.js'
|
|
12
10
|
|
|
11
|
+
export type Hook = Exclude<ClassicHookEvent, 'PreToolUse'>
|
|
12
|
+
|
|
13
|
+
export type HookAnswer = {
|
|
14
|
+
decision?: 'block'
|
|
15
|
+
reason?: string
|
|
16
|
+
hookSpecificOutput?: {
|
|
17
|
+
hookEventName?: Hook
|
|
18
|
+
additionalContext?: string
|
|
19
|
+
permissionDecision?: 'deny' | 'ask'
|
|
20
|
+
permissionDecisionReason?: string
|
|
21
|
+
sessionTitle?: string
|
|
22
|
+
initialUserMessage?: string
|
|
23
|
+
watchPaths?: string[]
|
|
24
|
+
reloadSkills?: boolean
|
|
25
|
+
decision?: PermissionRequestDecision
|
|
26
|
+
retry?: boolean
|
|
27
|
+
displayContent?: string
|
|
28
|
+
worktreePath?: string
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type HookListener<H extends Hook> = (input: ClassicHookInputs[H]) => HookAnswer | void | Promise<HookAnswer | void>
|
|
33
|
+
|
|
13
34
|
export type RoutedEvent =
|
|
14
|
-
| `classic.${
|
|
35
|
+
| `classic.${Hook}`
|
|
15
36
|
| 'tool.check'
|
|
16
37
|
| 'tool.call'
|
|
17
38
|
| 'prompt.submit'
|
|
@@ -29,30 +50,30 @@ export type RoutedEvent =
|
|
|
29
50
|
| 'session.compact'
|
|
30
51
|
| 'session.receive'
|
|
31
52
|
| 'agent.spawn'
|
|
53
|
+
| 'turn.start'
|
|
32
54
|
|
|
33
55
|
export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
|
|
34
56
|
|
|
35
57
|
type ClassicFields = Record<string, unknown>
|
|
36
58
|
|
|
37
|
-
type CallFiles =
|
|
59
|
+
type CallFiles = { readonly read: string[]; readonly changed: string[] }
|
|
38
60
|
|
|
39
|
-
const flags: readonly string[] = ['
|
|
61
|
+
const flags: readonly string[] = ['reloadSkills', 'retry']
|
|
40
62
|
|
|
41
63
|
const readFields: Record<Hook, readonly string[]> = {
|
|
42
64
|
Setup: ['additionalContext'],
|
|
43
65
|
SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
|
|
44
66
|
SessionEnd: [],
|
|
45
|
-
UserPromptSubmit: ['additionalContext', 'sessionTitle'
|
|
46
|
-
UserPromptExpansion: ['additionalContext'
|
|
67
|
+
UserPromptSubmit: ['additionalContext', 'sessionTitle'],
|
|
68
|
+
UserPromptExpansion: ['additionalContext'],
|
|
47
69
|
InstructionsLoaded: [],
|
|
48
70
|
ConfigChange: [],
|
|
49
71
|
DirectoryAdded: [],
|
|
50
72
|
PreModelSwitch: ['permissionDecision', 'permissionDecisionReason'],
|
|
51
73
|
PostModelSwitch: ['additionalContext'],
|
|
52
|
-
PreToolUse: ['additionalContext', 'permissionDecision', 'permissionDecisionReason', 'updatedInput'],
|
|
53
74
|
PermissionRequest: ['decision'],
|
|
54
75
|
PermissionDenied: ['retry'],
|
|
55
|
-
PostToolUse: ['additionalContext'
|
|
76
|
+
PostToolUse: ['additionalContext'],
|
|
56
77
|
PostToolUseFailure: ['additionalContext'],
|
|
57
78
|
PostToolBatch: ['additionalContext'],
|
|
58
79
|
MessageDisplay: ['displayContent'],
|
|
@@ -74,80 +95,24 @@ const readFields: Record<Hook, readonly string[]> = {
|
|
|
74
95
|
WorktreeRemove: [],
|
|
75
96
|
}
|
|
76
97
|
|
|
77
|
-
export function classicHook<
|
|
78
|
-
const hookInputOf = async (e: unknown): Promise<unknown> => {
|
|
79
|
-
if (event !== 'PostToolUse' && event !== 'PostToolUseFailure') return e
|
|
80
|
-
const input = e as ClassicHookInputs['PostToolUse' | 'PostToolUseFailure']
|
|
81
|
-
const cwd = calls.cwdOf(input.tool_use_id) ?? input.cwd
|
|
82
|
-
return { ...input, files: await callFiles(name, claude, { tool: input.tool_name, input: inputOf(input.tool_input) }, cwd) }
|
|
83
|
-
}
|
|
84
|
-
const resultOf = async (e: unknown): Promise<ClassicFields | undefined> => {
|
|
85
|
-
const answer = await hook((await hookInputOf(e)) as HookInput<E>)
|
|
86
|
-
if (answer === undefined) return undefined
|
|
87
|
-
if (answer.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
|
|
88
|
-
return classicResult(event, answer)
|
|
89
|
-
}
|
|
98
|
+
export function classicHook<H extends Hook>(name: string, event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
|
|
90
99
|
const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
|
|
91
|
-
const ours = await
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
const ours = await Promise.resolve()
|
|
101
|
+
.then(async () => {
|
|
102
|
+
const answer = await hook(e as ClassicHookInputs[H])
|
|
103
|
+
return answer === undefined ? undefined : classicResult(event, answer)
|
|
104
|
+
})
|
|
105
|
+
.catch((error: unknown): ClassicFields | undefined => {
|
|
106
|
+
if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: `${name}: the ${event} listener failed: ${messageOf(error)}` } }
|
|
107
|
+
report({ id: `hook:${event}`, message: `The ${event} listener failed`, error })
|
|
108
|
+
return undefined
|
|
109
|
+
})
|
|
96
110
|
if (ours === undefined) return next(e)
|
|
97
111
|
return mergeClassic((await next(e)) as ClassicFields, ours)
|
|
98
112
|
}
|
|
99
113
|
return routed as unknown as RoutedHook<RoutedEvent>
|
|
100
114
|
}
|
|
101
115
|
|
|
102
|
-
type HeldDecision = { readonly decision: 'allow' | 'ask'; readonly reason?: string }
|
|
103
|
-
|
|
104
|
-
export type HeldDecisions = Map<string, HeldDecision>
|
|
105
|
-
|
|
106
|
-
export function preToolUseHook(name: string, hook: HookListener<'PreToolUse'>, claude: Claude, calls: ToolCalls, held: HeldDecisions | undefined): RoutedHook<'tool.call'> {
|
|
107
|
-
return async (e, next) => {
|
|
108
|
-
let ours: ClassicFields
|
|
109
|
-
try {
|
|
110
|
-
const answer = await hook(await preToolUseInput(name, e, claude, calls.agentOf))
|
|
111
|
-
if (answer?.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
|
|
112
|
-
ours = answer === undefined ? {} : classicResult('PreToolUse', answer)
|
|
113
|
-
} catch (error) {
|
|
114
|
-
return { deny: `${name}: the PreToolUse hook failed: ${messageOf(error)}` }
|
|
115
|
-
}
|
|
116
|
-
if (typeof ours['deny'] === 'string') return { deny: ours['deny'] }
|
|
117
|
-
const decided = heldDecisionOf(ours)
|
|
118
|
-
if (decided !== undefined && held === undefined) {
|
|
119
|
-
return { deny: `${name}: the PreToolUse hook answered permissionDecision "${decided.decision}", so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.` }
|
|
120
|
-
}
|
|
121
|
-
const updated = ours['updatedInput'] as Record<string, unknown> | undefined
|
|
122
|
-
const reserved = Object.fromEntries(Object.entries(e).filter(([key]) => reservedKeys.includes(key)))
|
|
123
|
-
const earlier = held?.get(e.tool_use_id)
|
|
124
|
-
if (decided !== undefined) held?.set(e.tool_use_id, earlier !== undefined && strictness.indexOf(earlier.decision) >= strictness.indexOf(decided.decision) ? earlier : decided)
|
|
125
|
-
let result: EventResult<'tool.call'>
|
|
126
|
-
try {
|
|
127
|
-
result = await next(updated === undefined ? e : ({ ...reserved, ...updated } as Frozen<Args<'tool.call'>>))
|
|
128
|
-
} finally {
|
|
129
|
-
if (decided !== undefined) held?.delete(e.tool_use_id)
|
|
130
|
-
}
|
|
131
|
-
const context = ours['additionalContext'] as string[] | undefined
|
|
132
|
-
if (context === undefined || result.deny !== undefined) return result
|
|
133
|
-
return { ...result, context: [...(result.context ?? []), ...context] }
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function heldDecisionHook(held: HeldDecisions): RoutedHook<'tool.check'> {
|
|
138
|
-
return async (e, next) => {
|
|
139
|
-
const below = await next(e)
|
|
140
|
-
const ours = e.tool_use_id === undefined ? undefined : held.get(e.tool_use_id)
|
|
141
|
-
return ours === undefined || below.decision === 'deny' ? below : ours
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function heldDecisionOf(ours: ClassicFields): HeldDecision | undefined {
|
|
146
|
-
if (ours['allow'] !== undefined) return { decision: 'allow' }
|
|
147
|
-
if (typeof ours['ask'] !== 'string') return undefined
|
|
148
|
-
return ours['ask'] === '' ? { decision: 'ask' } : { decision: 'ask', reason: ours['ask'] }
|
|
149
|
-
}
|
|
150
|
-
|
|
151
116
|
const frontmatter = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/
|
|
152
117
|
|
|
153
118
|
const baseDirectoryLine = /^Base directory for this skill: [^\n]*\n\n/
|
|
@@ -169,24 +134,7 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
|
|
|
169
134
|
}
|
|
170
135
|
}
|
|
171
136
|
|
|
172
|
-
async function
|
|
173
|
-
const { tool, tool_use_id } = envelope
|
|
174
|
-
const [session_id, cwd, { agentId, agentType }] = await Promise.all([claude.session.id(), claude.session.cwd(), agentOf(tool_use_id)])
|
|
175
|
-
const tool_input = toolInputOf(envelope)
|
|
176
|
-
return {
|
|
177
|
-
session_id,
|
|
178
|
-
cwd,
|
|
179
|
-
hook_event_name: 'PreToolUse',
|
|
180
|
-
tool_name: tool,
|
|
181
|
-
tool_input,
|
|
182
|
-
tool_use_id,
|
|
183
|
-
...(agentId === undefined ? {} : { agent_id: agentId }),
|
|
184
|
-
...(agentType === undefined ? {} : { agent_type: agentType }),
|
|
185
|
-
files: await callFiles(name, claude, { tool, input: tool_input }, cwd),
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
|
|
137
|
+
export async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
|
|
190
138
|
try {
|
|
191
139
|
const [home, configRoot] = await Promise.all([claude.env.home(), claude.env.configHome()])
|
|
192
140
|
if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
|
|
@@ -206,32 +154,16 @@ async function callFiles(name: string, claude: Claude, use: CallBase, cwd: strin
|
|
|
206
154
|
|
|
207
155
|
function classicResult(event: Hook, answer: HookAnswer): ClassicFields {
|
|
208
156
|
const result: ClassicFields = {}
|
|
209
|
-
|
|
210
|
-
if (event === 'PreToolUse') {
|
|
211
|
-
if (answer.continue === false || answer.stopReason !== undefined) throw unread('continue or stopReason')
|
|
212
|
-
if (answer.decision === 'block') result['deny'] = answer.reason ?? ''
|
|
213
|
-
} else {
|
|
214
|
-
if (answer.continue === false) result['preventContinuation'] = true
|
|
215
|
-
if (answer.stopReason !== undefined) result['stopReason'] = answer.stopReason
|
|
216
|
-
if (answer.decision === 'block') result['block'] = answer.reason ?? ''
|
|
217
|
-
}
|
|
157
|
+
if (answer.decision === 'block') result['block'] = answer.reason ?? ''
|
|
218
158
|
const specific = answer.hookSpecificOutput ?? {}
|
|
219
|
-
if (specific.hookEventName !== undefined && specific.hookEventName !== event) {
|
|
220
|
-
throw new Error(`it answered hookSpecificOutput.hookEventName "${specific.hookEventName}". Set it to "${event}" or leave it out.`)
|
|
221
|
-
}
|
|
222
159
|
for (const [field, value] of Object.entries(specific)) {
|
|
223
|
-
if (field === 'hookEventName' ||
|
|
224
|
-
if (!readFields[event].includes(field)) throw
|
|
225
|
-
|
|
226
|
-
else if (field === 'permissionDecision' && event === 'PreToolUse') {
|
|
227
|
-
const reason = specific.permissionDecisionReason ?? ''
|
|
228
|
-
result[value as string] = value === 'allow' ? true : reason
|
|
229
|
-
} else if (field !== 'permissionDecisionReason' || event !== 'PreToolUse') result[field] = value
|
|
160
|
+
if (field === 'hookEventName' || (value === false && flags.includes(field))) continue
|
|
161
|
+
if (!readFields[event].includes(field)) throw new Error(`it answered ${field}, which ${event} does not read.`)
|
|
162
|
+
result[field] = field === 'additionalContext' ? [value] : value
|
|
230
163
|
}
|
|
231
164
|
return result
|
|
232
165
|
}
|
|
233
166
|
|
|
234
|
-
export const strictness = ['allow', 'ask', 'deny'] as const
|
|
235
167
|
|
|
236
168
|
function mergeClassic(below: ClassicFields, ours: ClassicFields): ClassicFields {
|
|
237
169
|
const merged: ClassicFields = { ...below, ...ours }
|