@cmodjs/core 2.0.1 → 3.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 +2 -2
- package/docs/jobs.md +1 -1
- package/docs/mod.md +4 -3
- package/docs/permissions.md +1 -1
- package/docs/session.md +12 -2
- package/docs/testing.md +6 -3
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +2 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +5 -2
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +2 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +4 -3
- 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 +58 -51
- package/runtime/before.d.ts +3 -2
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +10 -7
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +46 -19
- package/runtime/engine.d.ts +1 -0
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/hooks.d.ts +2 -2
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +7 -1
- package/runtime/lifecycle.d.ts +2 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +33 -15
- package/runtime/permission-check.d.ts +3 -0
- package/runtime/permission-check.d.ts.map +1 -1
- package/runtime/permission-check.js +19 -6
- package/runtime/router.d.ts +3 -2
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +19 -12
- package/runtime/sessions.d.ts +14 -4
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +72 -28
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +3 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +30 -2
- package/session/context/turn/message/note.d.ts +7 -0
- package/session/context/turn/message/note.d.ts.map +1 -1
- package/session/context/turn/message/note.js +12 -0
- package/session/session.d.ts +3 -1
- package/session/session.d.ts.map +1 -1
- package/src/jobs/context.ts +2 -1
- package/src/jobs/permissions.ts +11 -5
- package/src/jobs/prompt.ts +2 -1
- package/src/jobs/slash-command.ts +4 -3
- package/src/records.ts +1 -1
- package/src/register.ts +63 -52
- package/src/runtime/before.ts +13 -9
- package/src/runtime/conversations.ts +54 -27
- package/src/runtime/engine.ts +1 -0
- package/src/runtime/hooks.ts +9 -3
- package/src/runtime/lifecycle.ts +39 -19
- package/src/runtime/permission-check.ts +44 -22
- package/src/runtime/router.ts +23 -15
- package/src/runtime/sessions.ts +96 -38
- package/src/runtime/toaster.ts +2 -0
- package/src/session/context/turn/message/message.ts +27 -2
- package/src/session/context/turn/message/note.ts +16 -0
- package/src/session/session.ts +1 -2
- package/src/testing/fake-claude.ts +4 -3
- package/src/testing.ts +19 -12
- package/testing/fake-claude.d.ts +1 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +3 -2
- package/testing.d.ts +7 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +15 -9
|
@@ -4,6 +4,7 @@ import { defined, processPayloadOf, recordOf, type HookOn, type RouteOn, type To
|
|
|
4
4
|
|
|
5
5
|
export type PermissionUse = {
|
|
6
6
|
readonly raise: (method: string, payload: HookFields) => Promise<HookFields | undefined>
|
|
7
|
+
readonly refusalFor: (method: string, payload: HookFields, error: Error) => { readonly refuse: string } | undefined
|
|
7
8
|
readonly hookOn: HookOn
|
|
8
9
|
readonly routeOn: RouteOn
|
|
9
10
|
readonly current: () => { readonly id: string; readonly cwd: string }
|
|
@@ -15,33 +16,54 @@ type Verdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?:
|
|
|
15
16
|
|
|
16
17
|
const verdictName = (decision: Verdict['decision']) => (decision === 'deny' ? 'refuse' : decision)
|
|
17
18
|
|
|
18
|
-
export const permissionRoutes: PermissionRoutes = (method, { raise, hookOn, routeOn, current }) => {
|
|
19
|
+
export const permissionRoutes: PermissionRoutes = (method, { raise, refusalFor, hookOn, routeOn, current }) => {
|
|
19
20
|
const isBefore = method.startsWith('before')
|
|
20
21
|
if (method.endsWith('PermissionCheck')) {
|
|
21
|
-
|
|
22
|
-
const below = (await next(e)) as Verdict
|
|
22
|
+
const payloadOf = (e: ToolCheck, below: Verdict) => {
|
|
23
23
|
const { id, cwd } = current()
|
|
24
|
-
|
|
24
|
+
return { session: id, 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) }
|
|
25
|
+
}
|
|
26
|
+
const failed = async (e: ToolCheck, error: Error, next: (e: ToolCheck) => Promise<unknown>) => {
|
|
27
|
+
const refusal = refusalFor(method, payloadOf(e, (await next(e)) as Verdict), error)
|
|
28
|
+
return refusal === undefined ? undefined : { decision: 'deny' as const, reason: refusal.refuse }
|
|
29
|
+
}
|
|
30
|
+
return routeOn(
|
|
31
|
+
'tool.check',
|
|
32
|
+
async (e: ToolCheck, next) => {
|
|
33
|
+
const below = (await next(e)) as Verdict
|
|
34
|
+
const payload = payloadOf(e, below)
|
|
35
|
+
if (!isBefore) {
|
|
36
|
+
void raise(method, payload)
|
|
37
|
+
return below
|
|
38
|
+
}
|
|
39
|
+
const answer = await raise(method, payload)
|
|
40
|
+
if (answer === undefined || below.decision === 'deny') return below
|
|
41
|
+
if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
|
|
42
|
+
if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
|
|
43
|
+
return answer['allow'] === true ? { decision: 'allow' } : below
|
|
44
|
+
},
|
|
45
|
+
isBefore ? failed : undefined,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const refused = (refuse: string, interrupt?: unknown) => ({ decision: defined({ behavior: 'deny', message: refuse, interrupt }) })
|
|
49
|
+
return hookOn(
|
|
50
|
+
'PermissionRequest',
|
|
51
|
+
async (input) => {
|
|
52
|
+
const payload = processPayloadOf('PermissionRequest', input)
|
|
25
53
|
if (!isBefore) {
|
|
26
54
|
void raise(method, payload)
|
|
27
|
-
return
|
|
55
|
+
return undefined
|
|
28
56
|
}
|
|
29
57
|
const answer = await raise(method, payload)
|
|
30
|
-
if (answer === undefined
|
|
31
|
-
if (typeof answer['refuse'] === 'string') return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
const answer = await raise(method, payload)
|
|
43
|
-
if (answer === undefined) return undefined
|
|
44
|
-
if (typeof answer['refuse'] === 'string') return { decision: defined({ behavior: 'deny', message: answer['refuse'], interrupt: answer['interrupt'] }) }
|
|
45
|
-
return answer['allow'] === true ? { decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) } : undefined
|
|
46
|
-
})
|
|
58
|
+
if (answer === undefined) return undefined
|
|
59
|
+
if (typeof answer['refuse'] === 'string') return refused(answer['refuse'], answer['interrupt'])
|
|
60
|
+
return answer['allow'] === true ? { decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) } : undefined
|
|
61
|
+
},
|
|
62
|
+
isBefore
|
|
63
|
+
? (input, error) => {
|
|
64
|
+
const refusal = refusalFor(method, processPayloadOf('PermissionRequest', input), error)
|
|
65
|
+
return refusal === undefined ? undefined : refused(refusal.refuse)
|
|
66
|
+
}
|
|
67
|
+
: undefined,
|
|
68
|
+
)
|
|
47
69
|
}
|
package/src/runtime/router.ts
CHANGED
|
@@ -6,38 +6,46 @@ export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<
|
|
|
6
6
|
|
|
7
7
|
export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: RouterNext<N>) => EventResult<N> | Promise<EventResult<N>>
|
|
8
8
|
|
|
9
|
+
export type RoutedFailure<N extends RoutedEvent> = (e: Frozen<Args<N>>, error: Error, next: RouterNext<N>) => EventResult<N> | undefined | Promise<EventResult<N> | undefined>
|
|
10
|
+
|
|
9
11
|
export type Router = {
|
|
10
|
-
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): Disposable
|
|
11
|
-
has(event: RoutedEvent): boolean
|
|
12
|
+
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>, failed?: RoutedFailure<N>): Disposable
|
|
12
13
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
14
|
+
dispatchFailure<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, error: Error, next: RouterNext<N>): Promise<EventResult<N>>
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
type
|
|
17
|
+
type Route = { readonly hook: (e: unknown, next: (e: unknown) => Promise<unknown>) => unknown; readonly failed?: (e: unknown, error: Error, next: (e: unknown) => Promise<unknown>) => unknown }
|
|
16
18
|
|
|
17
19
|
export function createRouter(): Router {
|
|
18
|
-
const
|
|
20
|
+
const routes = new Map<RoutedEvent, Route[]>()
|
|
19
21
|
|
|
20
22
|
return {
|
|
21
|
-
add(event, hook) {
|
|
22
|
-
const
|
|
23
|
-
|
|
23
|
+
add(event, hook, failed) {
|
|
24
|
+
const route = { hook, ...(failed === undefined ? {} : { failed }) } as Route
|
|
25
|
+
routes.set(event, [...(routes.get(event) ?? []), route])
|
|
24
26
|
return {
|
|
25
27
|
dispose() {
|
|
26
|
-
const chain = (
|
|
27
|
-
if (chain.length === 0)
|
|
28
|
-
else
|
|
28
|
+
const chain = (routes.get(event) ?? []).filter((each) => each !== route)
|
|
29
|
+
if (chain.length === 0) routes.delete(event)
|
|
30
|
+
else routes.set(event, chain)
|
|
29
31
|
},
|
|
30
32
|
}
|
|
31
33
|
},
|
|
32
|
-
has: (event) => hooks.has(event),
|
|
33
34
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
34
|
-
const chain =
|
|
35
|
+
const chain = routes.get(event)
|
|
35
36
|
const call = async (index: number, current: unknown): Promise<unknown> => {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
return hook(current, (passed) => call(index + 1, passed))
|
|
37
|
+
const route = chain?.[index]
|
|
38
|
+
if (route === undefined) return next(current as Frozen<Args<N>>)
|
|
39
|
+
return route.hook(current, (passed) => call(index + 1, passed))
|
|
39
40
|
}
|
|
40
41
|
return call(0, e) as Promise<EventResult<N>>
|
|
41
42
|
},
|
|
43
|
+
async dispatchFailure<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, error: Error, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
44
|
+
for (const route of routes.get(event) ?? []) {
|
|
45
|
+
const answer = await route.failed?.(e, error, next as (e: unknown) => Promise<unknown>)
|
|
46
|
+
if (answer !== undefined) return answer as EventResult<N>
|
|
47
|
+
}
|
|
48
|
+
return next(e)
|
|
49
|
+
},
|
|
42
50
|
}
|
|
43
51
|
}
|
package/src/runtime/sessions.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Args, CommandRunResult } from 'claude-code'
|
|
1
|
+
import type { Args, CommandRunResult, SessionCompactResult } from 'claude-code'
|
|
2
2
|
import type { Disposable } from '../mod.js'
|
|
3
3
|
import { ContextEnd, ContextStart, type Context } from '../session/context/context.js'
|
|
4
4
|
import { Message, noFiles } from '../session/context/turn/message/message.js'
|
|
5
|
-
import { Note } from '../session/context/turn/message/note.js'
|
|
5
|
+
import { modNote, Note } from '../session/context/turn/message/note.js'
|
|
6
6
|
import { Person, senderOf } from '../session/context/turn/message/sender.js'
|
|
7
7
|
import { ToolCall } from '../session/context/turn/message/tool-call.js'
|
|
8
8
|
import { ToolResult } from '../session/context/turn/message/tool-result.js'
|
|
@@ -20,7 +20,7 @@ import { listenerOf, PassedTimeout, withinTime, type ListenOptions, type ListenT
|
|
|
20
20
|
import { callFiles, type HomeFolders, type HookFields } from './hooks.js'
|
|
21
21
|
import type { PermissionRoutes } from './permission-check.js'
|
|
22
22
|
|
|
23
|
-
export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
|
|
23
|
+
export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined; readonly onFailure?: 'refuse'; readonly scope?: Scope }
|
|
24
24
|
|
|
25
25
|
export const everySession = '*'
|
|
26
26
|
|
|
@@ -67,6 +67,7 @@ export type SessionsClient = {
|
|
|
67
67
|
readonly sessions: Sessions
|
|
68
68
|
changed(session: string, change: SessionChange, isInProject: boolean): void
|
|
69
69
|
answerBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<unknown>
|
|
70
|
+
failedBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean, error: Error): { readonly refuse: string } | undefined
|
|
70
71
|
raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
|
|
71
72
|
listen(method: string, args: readonly unknown[]): Disposable
|
|
72
73
|
/** An empty title keeps the title the session already has. */
|
|
@@ -133,7 +134,11 @@ export function isInScope(scope: Scope, session: string, change: SessionChange,
|
|
|
133
134
|
return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
export
|
|
137
|
+
export function withoutPermissionCheck(name: string, hearer: string): string {
|
|
138
|
+
return `${name}: ${hearer} hears Claude Code's permission check, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>, failed?: (input: unknown, error: Error) => HookFields | undefined) => Disposable
|
|
137
142
|
|
|
138
143
|
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 {
|
|
139
144
|
const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
|
|
@@ -151,7 +156,8 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
151
156
|
queued.add(key)
|
|
152
157
|
void Promise.resolve().then(async () => {
|
|
153
158
|
queued.delete(key)
|
|
154
|
-
const heard = (from: Map<string, Set<Listening>>): Subscription[] =>
|
|
159
|
+
const heard = (from: Map<string, Set<Listening>>): Subscription[] =>
|
|
160
|
+
[...(from.get(key) ?? [])].map(({ event, matcher, options, scope }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher), ...(options.onFailure === 'refuse' ? { onFailure: 'refuse' as const, scope } : {}) }))
|
|
155
161
|
const tracksTasks = [...(listening.get(key) ?? []), ...(deciding.get(key) ?? [])].some(({ scope }) => scope.task !== undefined || scope.isGoal === true)
|
|
156
162
|
const subscriptions = [...heard(listening), ...(tracksTasks ? [{ event: TaskStart }] : [])]
|
|
157
163
|
await call(conversationMethods.subscribe, { session: key, subscriptions }).catch((error: unknown) => report(`sessions:${key}`, `Hears nothing from ${key === everySession ? 'the other sessions' : `session ${key}`}`, error))
|
|
@@ -196,27 +202,32 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
196
202
|
}
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
|
|
205
|
+
const refusalOf = (options: ListenOptions, takes: readonly string[], error: unknown) => (options.onFailure === 'refuse' && takes.includes('refuse') ? { refuse: `${name}: ${messageOf(error)}` } : undefined)
|
|
200
206
|
|
|
201
207
|
const invoke = (method: string, options: ListenOptions, run: (time: ListenTime) => unknown, takes: readonly string[] = []): Promise<unknown> =>
|
|
202
208
|
withinTime(claude.clock, method, options, run).catch((error: unknown) => {
|
|
203
209
|
if (error instanceof PassedTimeout && !method.startsWith('before')) return undefined
|
|
204
210
|
report(`session:${method}`, `The ${method} listener failed`, error)
|
|
205
|
-
return options
|
|
211
|
+
return refusalOf(options, takes, error)
|
|
206
212
|
})
|
|
207
213
|
|
|
214
|
+
const decidersOf = (session: string, event: BeforeEvent, isInProject: boolean) => [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])].filter((entry) => entry.event === event)
|
|
215
|
+
|
|
216
|
+
const decides = (entry: Listening, session: string, change: SessionChange) => isInScope(entry.scope, session, change, isUnderIn(session)) && (entry.matcher === undefined || matches(payloadOf(change), entry.matcher))
|
|
217
|
+
|
|
218
|
+
const mayDecide = (session: string) => session === current.id || isGranted('conversation')
|
|
219
|
+
|
|
208
220
|
const combined = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<Partial<Answers> | undefined> => {
|
|
209
|
-
const askers =
|
|
210
|
-
.filter((entry) => entry.event === event)
|
|
221
|
+
const askers = decidersOf(session, event, isInProject)
|
|
211
222
|
.map((entry) => async (latest: SessionChange) => {
|
|
212
|
-
if (!
|
|
213
|
-
if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
|
|
223
|
+
if (!decides(entry, session, latest)) return undefined
|
|
214
224
|
const payload = await payloadFor(session, latest)
|
|
215
225
|
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
226
|
if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
|
|
217
227
|
throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
|
|
218
228
|
})
|
|
219
|
-
|
|
229
|
+
const answer = await foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
|
|
230
|
+
return answer?.note === undefined ? answer : { ...answer, note: modNote(name, answer.note) }
|
|
220
231
|
}
|
|
221
232
|
|
|
222
233
|
const eventMethods = (scopeOf: () => Scope, hasBefore: boolean, ended?: string): OnMethods & Partial<BeforeMethods> => {
|
|
@@ -325,7 +336,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
325
336
|
const sendHere = async ({ type, text, as }: SessionSend) => {
|
|
326
337
|
if (type === Note) {
|
|
327
338
|
if (as !== undefined) throw new Error(`${name}: a Note is always from this mod, so it takes no as.`)
|
|
328
|
-
const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
|
|
339
|
+
const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text: modNote(name, text) }] } })
|
|
329
340
|
if (added.deny !== undefined) throw new Error(`${name}: Claude Code refused the note: ${added.deny}`)
|
|
330
341
|
return
|
|
331
342
|
}
|
|
@@ -428,21 +439,26 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
428
439
|
)
|
|
429
440
|
}
|
|
430
441
|
|
|
431
|
-
type ProcessListener = { readonly matcher: unknown; readonly listener: (payload: HookFields, time: ListenTime) => unknown; readonly options: ListenOptions }
|
|
442
|
+
type ProcessListener = { readonly matcher: unknown; readonly listener: (payload: HookFields, time: ListenTime) => unknown; readonly options: ListenOptions; readonly isEveryAgent: boolean }
|
|
432
443
|
const processListeners = new Map<string, Set<ProcessListener>>()
|
|
433
444
|
|
|
445
|
+
const processHearers = (method: string, payload: HookFields) => [...(processListeners.get(method) ?? [])].filter(({ matcher, isEveryAgent }) => (payload['task'] === undefined || isEveryAgent) && (matcher === undefined || matches(payload, matcher)))
|
|
446
|
+
|
|
434
447
|
const raise = async (method: string, payload: HookFields): Promise<HookFields | undefined> => {
|
|
435
|
-
const heard =
|
|
448
|
+
const heard = processHearers(method, payload)
|
|
436
449
|
if (!method.startsWith('before')) {
|
|
437
450
|
await Promise.all(heard.map(({ listener, options }) => invoke(method, options, (time) => listener(payload, time))))
|
|
438
451
|
return undefined
|
|
439
452
|
}
|
|
440
|
-
const
|
|
441
|
-
const
|
|
442
|
-
const askers = heard.map(({ listener, options }) => () => invoke(method, { ...options, ...failing }, async (time) => takenAnswer(method, takes, await listener(payload, time)), takes))
|
|
453
|
+
const takes = takesOf(method)
|
|
454
|
+
const askers = heard.map(({ listener, options }) => () => invoke(method, options, async (time) => takenAnswer(method, takes, await listener(payload, time)), takes))
|
|
443
455
|
return foldAnswers<undefined, HookFields>(method, takes, ['refuse'], undefined, askers, (error) => report(`session:${method}`, `The ${method} listener failed`, error))
|
|
444
456
|
}
|
|
445
457
|
|
|
458
|
+
const takesOf = (method: string) => answerKeys[method.slice('before'.length)] ?? []
|
|
459
|
+
|
|
460
|
+
const refusalFor = (method: string, payload: HookFields, error: Error) => processHearers(method, payload).map((entry) => refusalOf(entry.options, takesOf(method), error)).find((answer) => answer !== undefined)
|
|
461
|
+
|
|
446
462
|
const commandRun = async (e: Args<'command.run'>): Promise<HookFields> => {
|
|
447
463
|
const source = (await claude.command.list()).find((command) => command.name === e.command)?.source
|
|
448
464
|
return { session: current.id, cwd: current.cwd, name: e.command, args: e.args, ...defined({ source }), from: senderOf(e.origin, false) }
|
|
@@ -461,7 +477,10 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
461
477
|
const args = typeof changes?.['args'] === 'string' ? changes['args'] : e.args
|
|
462
478
|
const ran = await next(args === e.args ? e : { ...e, args })
|
|
463
479
|
void raise('onRun', { ...run, args })
|
|
464
|
-
return typeof changes?.['note'] === 'string' ? { ...ran, context: [...(ran.context ?? []), changes['note']] } : ran
|
|
480
|
+
return typeof changes?.['note'] === 'string' ? { ...ran, context: [...(ran.context ?? []), modNote(name, changes['note'])] } : ran
|
|
481
|
+
}, async (e, error) => {
|
|
482
|
+
const refusal = commandListeners.beforeRun.size === 0 ? undefined : refusalFor('beforeRun', await commandRun(e), error)
|
|
483
|
+
return refusal === undefined ? undefined : { text: refusal.refuse }
|
|
465
484
|
})
|
|
466
485
|
|
|
467
486
|
const listenTo = (method: string, named: string) => {
|
|
@@ -469,24 +488,55 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
469
488
|
const isBefore = method.startsWith('before')
|
|
470
489
|
if (named === 'PermissionCheck' || named === 'PermissionRequest') {
|
|
471
490
|
const routes = permissionRoutes?.()
|
|
472
|
-
if (routes
|
|
473
|
-
return
|
|
491
|
+
if (routes === undefined) throw new Error(withoutPermissionCheck(name, method))
|
|
492
|
+
return routes(method, { raise, refusalFor, hookOn, routeOn, current: () => current })
|
|
474
493
|
}
|
|
475
494
|
const event: { readonly on: string; readonly before?: string } = processEvents[named as ProcessName]
|
|
476
495
|
const hook = (isBefore ? event.before : undefined) ?? event.on
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
496
|
+
if (method === 'beforeSessionStart') routeOn('session.compact', compactionNote)
|
|
497
|
+
const failed = (input: unknown, error: Error) => {
|
|
498
|
+
const refusal = refusalFor(method, processPayloadOf(named as ProcessFieldsName, input), error)
|
|
499
|
+
return refusal === undefined ? undefined : hookAnswerOf(name, named, refusal)
|
|
500
|
+
}
|
|
501
|
+
return hookOn(
|
|
502
|
+
hook,
|
|
503
|
+
async (input: unknown) => {
|
|
504
|
+
const payload = processPayloadOf(named as ProcessFieldsName, input)
|
|
505
|
+
if (named === 'SessionStart' && payload['source'] === 'compact') {
|
|
506
|
+
const started = isBefore ? compactionStarts.shift() : undefined
|
|
507
|
+
return started === undefined ? undefined : hookAnswerOf(name, named, started)
|
|
508
|
+
}
|
|
509
|
+
if (!isBefore) {
|
|
510
|
+
void raise(method, payload)
|
|
511
|
+
return undefined
|
|
512
|
+
}
|
|
513
|
+
const answer = await raise(method, payload)
|
|
514
|
+
return answer === undefined ? undefined : hookAnswerOf(name, named, answer)
|
|
515
|
+
},
|
|
516
|
+
isBefore ? failed : undefined,
|
|
517
|
+
)
|
|
486
518
|
}
|
|
487
519
|
|
|
488
|
-
const
|
|
489
|
-
|
|
520
|
+
const compactionStarts: HookFields[] = []
|
|
521
|
+
|
|
522
|
+
const compactionNote = async (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>): Promise<SessionCompactResult> => {
|
|
523
|
+
const compacted = await next(e)
|
|
524
|
+
if (e.trigger === 'precompute' || compacted.skip !== undefined) return compacted
|
|
525
|
+
const model = e.agentId === undefined ? await claude.session.model() : undefined
|
|
526
|
+
const answer = await raise('beforeSessionStart', { session: current.id, cwd: current.cwd, ...defined({ task: e.agentId, model, title: current.title || undefined, contextTokenCount: compacted.tokensAfter }), source: 'compact' })
|
|
527
|
+
const { note, ...started } = answer ?? {}
|
|
528
|
+
if (Object.keys(started).length > 0) compactionStarts.push(started)
|
|
529
|
+
if (typeof note !== 'string') return compacted
|
|
530
|
+
if (!isGranted('prompt')) {
|
|
531
|
+
report('session:beforeSessionStart', 'The beforeSessionStart listener failed', new Error(`${name}: its note adds text Claude reads, which needs the prompt permission.`))
|
|
532
|
+
return compacted
|
|
533
|
+
}
|
|
534
|
+
return { ...compacted, messages: [...compacted.messages, { role: 'user', text: modNote(name, note), toolUses: [] }] }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const listen = (method: string, args: readonly unknown[], isEveryAgent = false): Disposable => {
|
|
538
|
+
const heard = listenerOf(name, method, args)
|
|
539
|
+
const entry = { ...heard, options: method === 'beforePermissionRequest' ? { ...heard.options, onFailure: 'refuse' } : heard.options, isEveryAgent } as ProcessListener
|
|
490
540
|
const held = processListeners.get(method) ?? new Set()
|
|
491
541
|
if (!processListeners.has(method)) {
|
|
492
542
|
listenTo(method, method.replace(/^(on|before)/, ''))
|
|
@@ -501,10 +551,16 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
501
551
|
Object.entries(processEvents).flatMap(([named, event]) => [`on${named}`, ...('before' in event ? [`before${named}`] : [])]).map((method) => [method, (...args: unknown[]) => listen(method, args)]),
|
|
502
552
|
) as ProcessOnMethods & ProcessBeforeMethods
|
|
503
553
|
|
|
504
|
-
const
|
|
554
|
+
const here = sessionOf(() => current)
|
|
555
|
+
const currentSession = Object.assign(here, processMethods(), { goal: Object.assign(here.goal, { beforeSessionStart: (...args: unknown[]) => listen('beforeSessionStart', args, true) }) })
|
|
505
556
|
const every = eventMethods(() => ({}), false)
|
|
506
557
|
|
|
507
|
-
const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (session
|
|
558
|
+
const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (mayDecide(session) ? combined(session, event, change, isInProject) : undefined)
|
|
559
|
+
|
|
560
|
+
const failedBefore = (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean, error: Error) => {
|
|
561
|
+
const asked = mayDecide(session) ? decidersOf(session, event, isInProject).filter((entry) => decides(entry, session, change)) : []
|
|
562
|
+
return asked.map((entry) => refusalOf(entry.options, beforeAnswerKeys[event], error)).find((answer) => answer !== undefined)
|
|
563
|
+
}
|
|
508
564
|
|
|
509
565
|
return {
|
|
510
566
|
sessions: {
|
|
@@ -537,6 +593,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
537
593
|
}
|
|
538
594
|
},
|
|
539
595
|
answerBefore,
|
|
596
|
+
failedBefore,
|
|
540
597
|
raise,
|
|
541
598
|
listen,
|
|
542
599
|
follow(next) {
|
|
@@ -561,8 +618,9 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
561
618
|
export type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
|
|
562
619
|
|
|
563
620
|
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
|
|
621
|
+
(event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>, failed?: (e: ToolCheck, error: Error, next: (e: ToolCheck) => Promise<unknown>) => Promise<{ readonly decision: 'deny'; readonly reason: string } | undefined>): Disposable
|
|
622
|
+
(event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>, failed?: (e: Args<'command.run'>, error: Error) => Promise<CommandRunResult | undefined>): Disposable
|
|
623
|
+
(event: 'session.compact', hook: (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>) => Promise<SessionCompactResult>): Disposable
|
|
566
624
|
}
|
|
567
625
|
|
|
568
626
|
export const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
|
|
@@ -659,9 +717,9 @@ export function processPayloadOf(named: ProcessFieldsName, input: unknown): Hook
|
|
|
659
717
|
return { ...placementOf(fields), ...defined(processFields[named](fields)) }
|
|
660
718
|
}
|
|
661
719
|
|
|
662
|
-
function hookAnswerOf(named: string, answer: HookFields): HookFields | undefined {
|
|
720
|
+
function hookAnswerOf(name: string, named: string, answer: HookFields): HookFields | undefined {
|
|
663
721
|
const refuse = typeof answer['refuse'] === 'string' ? answer['refuse'] : undefined
|
|
664
|
-
const note = typeof answer['note'] === 'string' ? [answer['note']] : undefined
|
|
722
|
+
const note = typeof answer['note'] === 'string' ? [modNote(name, answer['note'])] : undefined
|
|
665
723
|
if (named === 'SessionStart') return defined({ additionalContext: note, sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'] === true ? true : undefined, initialUserMessage: answer['firstPrompt'] })
|
|
666
724
|
if (named === 'ModelChange') {
|
|
667
725
|
if (refuse !== undefined) return { permissionDecision: 'deny', permissionDecisionReason: refuse }
|
package/src/runtime/toaster.ts
CHANGED
|
@@ -180,6 +180,8 @@ export function createToaster(claude: Claude, name: string, version: () => strin
|
|
|
180
180
|
const hides = `${version() ?? ''}\n${message}\n${error === undefined ? '' : describe(error)}`
|
|
181
181
|
if (hidden.get(id) === hides) return id
|
|
182
182
|
hidden.delete(id)
|
|
183
|
+
const shown = entries.get(id)
|
|
184
|
+
if (shown?.hides === hides && shown.trying === undefined && shown.options.description === description) return id
|
|
183
185
|
const stack = error instanceof Error && error.stack !== undefined ? `\n\n${error.stack}` : ''
|
|
184
186
|
const details = async () => {
|
|
185
187
|
const modVersion = await claude.fs
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { isObject } from '../../../../records.js'
|
|
2
2
|
import { Task } from '../../../goal/task.js'
|
|
3
3
|
import { InstructionFile, isRulePath } from './instruction-file.js'
|
|
4
|
-
import { Note } from './note.js'
|
|
4
|
+
import { modNote, modNoteBlock, modNotesIn, Note } from './note.js'
|
|
5
5
|
import { Notice } from './notice.js'
|
|
6
6
|
import { Prompt } from './prompt.js'
|
|
7
7
|
import { Reply } from './reply.js'
|
|
8
|
-
import { senderOf } from './sender.js'
|
|
8
|
+
import { Mod, senderOf } from './sender.js'
|
|
9
9
|
import { Skill } from './skill.js'
|
|
10
10
|
import { ToolCall, type CallFiles } from './tool-call.js'
|
|
11
11
|
import { ToolResult } from './tool-result.js'
|
|
@@ -108,8 +108,11 @@ export function messagesOf(row: Row, isInTask: boolean): Unplaced[] {
|
|
|
108
108
|
function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
|
|
109
109
|
const base = { id: row.id, at: row.at }
|
|
110
110
|
const text = textOf(row.content).trim()
|
|
111
|
+
const notesFrom = (each: readonly { readonly mod: string; readonly text: string }[]): Unplaced[] => each.map((note) => ({ ...base, type: Note, text: note.text, from: { type: Mod, name: note.mod } }))
|
|
111
112
|
if (row.type === 'system') return row.name === 'turn_duration' || row.name === 'compact_boundary' || text === '' ? [] : [{ ...base, type: Notice, text }]
|
|
113
|
+
if (row.type === 'attachment' && row.name === 'hook_additional_context') return notesFrom(row.content.flatMap((entry) => modNotesIn(typeof entry === 'string' ? entry : textOf([entry]))))
|
|
112
114
|
if (row.type === 'attachment') return row.name === 'nested_memory' ? [{ ...base, type: InstructionFile, path: row.path ?? '', text, isRule: isRulePath(row.path ?? '') }] : []
|
|
115
|
+
if (isModNotesRow(row)) return notesFrom(modNotesIn(text))
|
|
113
116
|
if (row.type === 'assistant') {
|
|
114
117
|
return row.content.filter(isObject).flatMap((block): Unplaced[] => {
|
|
115
118
|
if (block['type'] === 'text' && typeof block['text'] === 'string' && block['text'].trim() !== '') return [{ ...base, type: Reply, text: block['text'].trim() }]
|
|
@@ -128,7 +131,29 @@ function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
|
|
|
128
131
|
return prompt === undefined ? [] : [{ ...base, type: Prompt, text: prompt, from: senderOf(row.origin, isInTask) }]
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
function isModNotesRow(row: Row): boolean {
|
|
135
|
+
const text = textOf(row.content)
|
|
136
|
+
return row.type === 'user' && !row.content.some(isToolResultBlock) && modNotesIn(text).length > 0 && text.replace(modNoteBlock, '').trim() === ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function withNoteTexts(entries: readonly unknown[], texts: ReadonlyMap<number, string>): unknown[] {
|
|
140
|
+
let index = -1
|
|
141
|
+
const replaced = (text: string) =>
|
|
142
|
+
text.replace(modNoteBlock, (whole, mod: string) => {
|
|
143
|
+
index += 1
|
|
144
|
+
const changed = texts.get(index)
|
|
145
|
+
if (changed === undefined) return whole
|
|
146
|
+
return changed === '' ? '' : modNote(mod, changed)
|
|
147
|
+
})
|
|
148
|
+
return entries.map((entry) => {
|
|
149
|
+
if (typeof entry === 'string') return replaced(entry)
|
|
150
|
+
return isObject(entry) && entry['type'] === 'text' && typeof entry['text'] === 'string' ? { ...entry, text: replaced(entry['text']) } : entry
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
export function withTexts(row: Row, texts: ReadonlyMap<number, string>): readonly unknown[] {
|
|
155
|
+
if (row.type === 'attachment' && row.name === 'hook_additional_context') return withNoteTexts(row.content, texts)
|
|
156
|
+
if (isModNotesRow(row)) return keepingMedia(row.content, withNoteTexts([textOf(row.content)], texts)[0] as string)
|
|
132
157
|
if (row.type === 'assistant') {
|
|
133
158
|
let index = -1
|
|
134
159
|
return row.content.map((block) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pluginName } from '../../../../records.js'
|
|
1
2
|
import type { MessageOf } from './message.js'
|
|
2
3
|
import type { Sender } from './sender.js'
|
|
3
4
|
|
|
@@ -7,3 +8,18 @@ export type Note = MessageOf<typeof Note> & {
|
|
|
7
8
|
readonly text: string
|
|
8
9
|
readonly from: Sender
|
|
9
10
|
}
|
|
11
|
+
|
|
12
|
+
export const modNoteBlock = new RegExp(`<(${pluginName.source.slice(1, -1)})_mod>\\n([\\s\\S]*?)\\n</\\1_mod>`, 'g')
|
|
13
|
+
|
|
14
|
+
export function modNote(mod: string, text: string): string {
|
|
15
|
+
return `<${mod}_mod>\n${text}\n</${mod}_mod>`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function modNotesIn(text: string): { readonly mod: string; readonly text: string }[] {
|
|
19
|
+
return [...text.matchAll(modNoteBlock)].map((match) => ({ mod: match[1] as string, text: match[2] as string }))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function untagged(text: string): string {
|
|
23
|
+
const notes = modNotesIn(text)
|
|
24
|
+
return notes.length === 0 ? text : notes.map((note) => note.text).join('\n\n')
|
|
25
|
+
}
|
package/src/session/session.ts
CHANGED
|
@@ -120,7 +120,6 @@ export type OnMethods = { readonly [Name in EventName as `on${Name}`]: OnMethod<
|
|
|
120
120
|
|
|
121
121
|
export type BeforeMethods = { readonly [Name in BeforeName as `before${Name}`]: BeforeMethod<PayloadOf<Name>, AnswerOf<Name>> }
|
|
122
122
|
|
|
123
|
-
|
|
124
123
|
export type RecordPart = OnMethods &
|
|
125
124
|
BeforeMethods & {
|
|
126
125
|
search(text: string, matcher?: Matcher<Message>): AsyncIterable<Message>
|
|
@@ -327,7 +326,7 @@ export type ProcessOnMethods = { readonly [Name in ProcessName as `on${Name}`]:
|
|
|
327
326
|
|
|
328
327
|
export type ProcessBeforeMethods = { readonly [Name in keyof ProcessAnswer as `before${Name}`]: BeforeMethod<ProcessAsked[Name], ProcessAnswer[Name]> }
|
|
329
328
|
|
|
330
|
-
export type CurrentSession = Session & ProcessOnMethods & ProcessBeforeMethods
|
|
329
|
+
export type CurrentSession = Omit<Session, 'goal'> & ProcessOnMethods & ProcessBeforeMethods & { readonly goal: Goal & Pick<ProcessBeforeMethods, 'beforeSessionStart'> }
|
|
331
330
|
|
|
332
331
|
export type SessionScope = {
|
|
333
332
|
readonly running?: boolean
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EventResult, Timer } from 'claude-code'
|
|
2
2
|
import { isObject } from '../records.js'
|
|
3
3
|
import type { Claude } from '../runtime/claude.js'
|
|
4
|
+
import { untagged } from '../session/context/turn/message/note.js'
|
|
4
5
|
import { elements } from './fake-elements.js'
|
|
5
6
|
import { fakeFiles } from './fake-files.js'
|
|
6
7
|
|
|
@@ -21,7 +22,7 @@ export type Fakes = {
|
|
|
21
22
|
prompt: { submit?: Claude['prompt']['submit'] }
|
|
22
23
|
model: { complete?: Claude['model']['complete'] }
|
|
23
24
|
agent: { list?: Claude['agent']['list']; spawn?: Claude['agent']['spawn'] }
|
|
24
|
-
clock: { after?: Claude['clock']['after']; every?: Claude['clock']['every'] }
|
|
25
|
+
clock: { now?: Claude['clock']['now']; after?: Claude['clock']['after']; every?: Claude['clock']['every'] }
|
|
25
26
|
cmod: { call?: Claude['cmod']['call'] }
|
|
26
27
|
tool: { call?: Claude['tool']['call'] }
|
|
27
28
|
}
|
|
@@ -79,7 +80,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
const appendNote: Claude['session']['append'] = async ({ message }) => {
|
|
82
|
-
shown.notes.push(message.content.map((block) => (typeof block['text'] === 'string' ? block['text'] : '')).join(''))
|
|
83
|
+
shown.notes.push(untagged(message.content.map((block) => (typeof block['text'] === 'string' ? block['text'] : '')).join('')))
|
|
83
84
|
return { message, uuid: `note-${shown.notes.length}` }
|
|
84
85
|
}
|
|
85
86
|
const submitPrompt: Claude['prompt']['submit'] = async ({ text }) => {
|
|
@@ -175,7 +176,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
|
|
|
175
176
|
keys: async () => [...store.keys()],
|
|
176
177
|
},
|
|
177
178
|
clock: {
|
|
178
|
-
now:
|
|
179
|
+
now: () => (fakes.clock.now === undefined ? Promise.resolve(Date.now()) : fakes.clock.now()),
|
|
179
180
|
after: faked('clock.after', () => fakes.clock.after ?? realTimer),
|
|
180
181
|
every: faked('clock.every', () => fakes.clock.every ?? idleInterval),
|
|
181
182
|
},
|