@cmodjs/core 2.0.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 +3 -3
- package/docs/dependencies.md +1 -1
- 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 +5 -4
- package/runtime/before.d.ts +6 -3
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +59 -37
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +38 -13
- package/runtime/hooks.d.ts +9 -22
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +4 -58
- package/runtime/lifecycle.d.ts +2 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +34 -17
- 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 +12 -8
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +41 -80
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +14 -11
- package/session/context/turn/message/message.d.ts +1 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +5 -2
- package/src/records.ts +1 -1
- package/src/register.ts +5 -4
- package/src/runtime/before.ts +60 -39
- package/src/runtime/conversations.ts +35 -13
- package/src/runtime/hooks.ts +13 -84
- package/src/runtime/lifecycle.ts +49 -31
- package/src/runtime/permission-check.ts +47 -0
- package/src/runtime/sessions.ts +51 -84
- package/src/runtime/state.ts +15 -8
- package/src/session/context/turn/message/message.ts +6 -2
- package/src/testing.ts +4 -3
- package/testing.d.ts.map +1 -1
- package/testing.js +4 -3
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { serveConversations } from './conversations.js'
|
|
|
13
13
|
import { answerCall, answeringOwnCalls, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
|
|
14
14
|
import { keepEngine } from './engine.js'
|
|
15
15
|
import { classicHook, permissionEvents, userSkillHook, type Hook, type HookListener, type RoutedEvent } from './hooks.js'
|
|
16
|
+
import type { PermissionRoutes } from './permission-check.js'
|
|
16
17
|
import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
|
|
17
18
|
import { cmodError, lastLineOf, locatingPrograms, readLines, type Programs } from './programs.js'
|
|
18
19
|
import { createModFiles, resolvedPath, type Places } from './metadata.js'
|
|
@@ -63,7 +64,7 @@ type ModRuntime = {
|
|
|
63
64
|
readonly steps: Steps
|
|
64
65
|
readonly granted: ReadonlySet<string>
|
|
65
66
|
readonly refreshGrants: () => Promise<void>
|
|
66
|
-
readonly
|
|
67
|
+
readonly permissionRoutes: () => PermissionRoutes | undefined
|
|
67
68
|
readonly options: PluginOptions
|
|
68
69
|
}
|
|
69
70
|
|
|
@@ -85,6 +86,8 @@ const cmodCheckMs = 1000
|
|
|
85
86
|
|
|
86
87
|
const cmodWaitMs = 60_000
|
|
87
88
|
|
|
89
|
+
const cmodSlowCheckMs = 10_000
|
|
90
|
+
|
|
88
91
|
const startHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
|
|
89
92
|
|
|
90
93
|
const dependencyCallMs = 30_000
|
|
@@ -153,7 +156,7 @@ async function cmodVersion(claude: Claude): Promise<string | undefined> {
|
|
|
153
156
|
|
|
154
157
|
export function createLifecycle<State extends object, Declared extends Options = Options>(
|
|
155
158
|
definition: ModDefinition<State, string, Declared>,
|
|
156
|
-
|
|
159
|
+
permissionRoutes: () => PermissionRoutes | undefined = () => undefined,
|
|
157
160
|
options: PluginOptions = {},
|
|
158
161
|
asksPerson = true,
|
|
159
162
|
): Lifecycle<State> {
|
|
@@ -319,7 +322,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
319
322
|
const activate = async (): Promise<void> => {
|
|
320
323
|
if (runtime === undefined || plugin === undefined) return
|
|
321
324
|
const activeRouter = createRouter()
|
|
322
|
-
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), store: plugin.store, steps: plugin.steps, granted, refreshGrants,
|
|
325
|
+
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), store: plugin.store, steps: plugin.steps, granted, refreshGrants, permissionRoutes, options: pluginOptions }).catch(async (error: unknown) => {
|
|
323
326
|
if (error instanceof MissingOptions && (await askOptions(error))) return undefined
|
|
324
327
|
fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : undefined, begin)
|
|
325
328
|
throw error
|
|
@@ -335,12 +338,18 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
335
338
|
void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
|
|
336
339
|
const missed = missedSessionStart
|
|
337
340
|
missedSessionStart = undefined
|
|
338
|
-
const
|
|
341
|
+
const replay = (e: Frozen<Args<'classic.SessionStart'>>) => (definition.name === cmodPluginName ? activeRouter.dispatch('classic.SessionStart', e, async () => ({})) : checkedRoute('classic.SessionStart', e, async () => ({})))
|
|
342
|
+
const lateAnswer = missed === undefined ? {} : await replay(missed).catch((error: unknown) => {
|
|
339
343
|
toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error })
|
|
340
344
|
return {}
|
|
341
345
|
})
|
|
342
|
-
|
|
343
|
-
|
|
346
|
+
const { additionalContext, ...unread } = lateAnswer as { readonly additionalContext?: readonly string[] } & Record<string, unknown>
|
|
347
|
+
for (const text of additionalContext ?? []) {
|
|
348
|
+
const added = await runtime.claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
|
|
349
|
+
if (added.deny !== undefined) runtime.claude.ui.log(`${definition.name}: Claude Code refused its SessionStart note: ${added.deny}`, { to: 'debug' })
|
|
350
|
+
}
|
|
351
|
+
if (Object.keys(unread).length > 0) {
|
|
352
|
+
runtime.claude.ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read ${Object.keys(unread).join(', ')} from its SessionStart hooks.`, { to: 'debug' })
|
|
344
353
|
}
|
|
345
354
|
const version = plugin.version ?? ''
|
|
346
355
|
const announced = announcedOf(await runtime.claude.store.get(announcedKey))
|
|
@@ -422,27 +431,31 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
422
431
|
timer.cancel()
|
|
423
432
|
longWait.cancel()
|
|
424
433
|
}
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
434
|
+
const poll = (ms: number) =>
|
|
435
|
+
claude().clock.every(ms, () => {
|
|
436
|
+
check ??= cmodVersion(claude())
|
|
437
|
+
.then((version) => {
|
|
438
|
+
check = undefined
|
|
439
|
+
latest = version
|
|
440
|
+
if (!fits(version)) return
|
|
441
|
+
stop()
|
|
442
|
+
return install()
|
|
443
|
+
})
|
|
444
|
+
.catch((error: unknown) => {
|
|
445
|
+
stop()
|
|
446
|
+
report(error)
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
let timer = poll(cmodCheckMs)
|
|
450
|
+
const longWait = claude().clock.after(cmodWaitMs, () => {
|
|
451
|
+
timer.cancel()
|
|
452
|
+
timer = poll(cmodSlowCheckMs)
|
|
440
453
|
installing(
|
|
441
454
|
latest === undefined
|
|
442
455
|
? "Still waiting for cmod to download cmod. See cmod's own line."
|
|
443
456
|
: `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.steps ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`,
|
|
444
|
-
)
|
|
445
|
-
)
|
|
457
|
+
)
|
|
458
|
+
})
|
|
446
459
|
}
|
|
447
460
|
|
|
448
461
|
const bootstrap = async (root: string) => {
|
|
@@ -595,7 +608,10 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
595
608
|
const isCallToThisMod = event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name
|
|
596
609
|
if (event === 'cmod.call' && !isCallToThisMod) return next(e)
|
|
597
610
|
await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
|
|
598
|
-
if (event === 'classic.SessionStart' && phase !== 'active')
|
|
611
|
+
if (event === 'classic.SessionStart' && phase !== 'active') {
|
|
612
|
+
missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
|
|
613
|
+
return next(e)
|
|
614
|
+
}
|
|
599
615
|
if (isCallToThisMod && phase !== 'active') {
|
|
600
616
|
const isStopped = phase === 'declined' || phase === 'failed'
|
|
601
617
|
return { deny: isStopped ? `${definition.name} is not set up. Its toast says why.` : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
|
|
@@ -688,7 +704,7 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
688
704
|
}))
|
|
689
705
|
const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
|
|
690
706
|
const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
|
|
691
|
-
const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>): Disposable => router.add(`classic.${hook}`, classicHook(
|
|
707
|
+
const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>): Disposable => router.add(`classic.${hook}`, classicHook(hook, listener, report))
|
|
692
708
|
const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
|
|
693
709
|
const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
|
|
694
710
|
read: () => checked.settings.read({ source }),
|
|
@@ -704,6 +720,7 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
704
720
|
isGranted: (permission) => flag(permission),
|
|
705
721
|
hookOn: (hook, listener) => hookOn(hook as Hook, listener as HookListener<Hook>),
|
|
706
722
|
routeOn: (event, hook) => router.add(event, hook as never),
|
|
723
|
+
permissionRoutes: runtime.permissionRoutes,
|
|
707
724
|
})
|
|
708
725
|
let followed: Parameters<typeof client.follow>[0] | undefined
|
|
709
726
|
let isSetUp = false
|
|
@@ -747,8 +764,8 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
747
764
|
return hookOn('ConfigChange', async ({ source, file_path }) => {
|
|
748
765
|
const change: SettingsFileChange = { source: source === 'skills' ? 'skills' : (source.replace('_settings', '') as SettingsFileChange['source']), ...(file_path === undefined ? {} : { path: file_path }) }
|
|
749
766
|
if (matcher !== undefined && !matches(change, matcher)) return undefined
|
|
750
|
-
const
|
|
751
|
-
return
|
|
767
|
+
const refuse = (await listener(change))?.refuse
|
|
768
|
+
return typeof refuse === 'string' ? { block: refuse } : undefined
|
|
752
769
|
})
|
|
753
770
|
},
|
|
754
771
|
},
|
|
@@ -769,9 +786,10 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
769
786
|
const watch = { paths, listener }
|
|
770
787
|
watches.add(watch)
|
|
771
788
|
if (watches.size === 1) {
|
|
772
|
-
hookOn('SessionStart', () => ({
|
|
789
|
+
hookOn('SessionStart', () => ({ watchPaths: [...new Set([...watches].flatMap((each) => each.paths))] }))
|
|
773
790
|
hookOn('FileChanged', ({ file_path, event }) => {
|
|
774
791
|
for (const each of watches) if (each.paths.some((watched) => relativePath(watched, file_path) !== undefined)) void Promise.resolve(each.listener({ path: file_path, type: event })).catch((error: unknown) => report({ id: 'fs:onChange', message: 'The fs.onChange listener failed', error }))
|
|
792
|
+
return undefined
|
|
775
793
|
})
|
|
776
794
|
}
|
|
777
795
|
return { dispose: () => void watches.delete(watch) }
|
|
@@ -955,7 +973,7 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
955
973
|
await failsAs('its setup function threw', () => definition.setup(mod))
|
|
956
974
|
isSetUp = true
|
|
957
975
|
const permissionHooks = permissionEvents.filter((event) => router.has(event))
|
|
958
|
-
if (permissionHooks.length > 0 &&
|
|
976
|
+
if (permissionHooks.length > 0 && runtime.permissionRoutes() === undefined) {
|
|
959
977
|
throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
|
|
960
978
|
}
|
|
961
979
|
await failsAs('its open panes did not load', () => area.restorePanes())
|
|
@@ -965,7 +983,7 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
965
983
|
function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
|
|
966
984
|
const bound = Object.entries(keys)
|
|
967
985
|
if (bound.length === 0) return claude
|
|
968
|
-
const
|
|
986
|
+
const addCommand: Claude['command']['register'] = (command) => {
|
|
969
987
|
for (const [key, commandName] of bound) {
|
|
970
988
|
if (commandName === command.name && command.immediate !== true) {
|
|
971
989
|
toast.warning(`${key} runs /${command.name}, which waits for Claude's turn to end`, { id: `key:${key}`, description: `It adds a row to the conversation. Give /${command.name} immediate: true.`, duration: Infinity })
|
|
@@ -973,7 +991,7 @@ function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string
|
|
|
973
991
|
}
|
|
974
992
|
return claude.command.register(command)
|
|
975
993
|
}
|
|
976
|
-
return { ...claude, command: { ...claude.command, register } }
|
|
994
|
+
return { ...claude, command: { ...claude.command, register: addCommand } }
|
|
977
995
|
}
|
|
978
996
|
|
|
979
997
|
const longestTimerMs = 2_147_483_647
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Disposable } from '../mod.js'
|
|
2
|
+
import type { HookFields } from './hooks.js'
|
|
3
|
+
import { defined, processPayloadOf, recordOf, type HookOn, type RouteOn, type ToolCheck } from './sessions.js'
|
|
4
|
+
|
|
5
|
+
export type PermissionUse = {
|
|
6
|
+
readonly raise: (method: string, payload: HookFields) => Promise<HookFields | undefined>
|
|
7
|
+
readonly hookOn: HookOn
|
|
8
|
+
readonly routeOn: RouteOn
|
|
9
|
+
readonly current: () => { readonly id: string; readonly cwd: string }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type PermissionRoutes = (method: string, use: PermissionUse) => Disposable
|
|
13
|
+
|
|
14
|
+
type Verdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?: string; readonly rule?: string }
|
|
15
|
+
|
|
16
|
+
const verdictName = (decision: Verdict['decision']) => (decision === 'deny' ? 'refuse' : decision)
|
|
17
|
+
|
|
18
|
+
export const permissionRoutes: PermissionRoutes = (method, { raise, hookOn, routeOn, current }) => {
|
|
19
|
+
const isBefore = method.startsWith('before')
|
|
20
|
+
if (method.endsWith('PermissionCheck')) {
|
|
21
|
+
return routeOn('tool.check', async (e: ToolCheck, next) => {
|
|
22
|
+
const below = (await next(e)) as Verdict
|
|
23
|
+
const { id, cwd } = current()
|
|
24
|
+
const payload = { 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
|
+
if (!isBefore) {
|
|
26
|
+
void raise(method, payload)
|
|
27
|
+
return below
|
|
28
|
+
}
|
|
29
|
+
const answer = await raise(method, payload)
|
|
30
|
+
if (answer === undefined || below.decision === 'deny') return below
|
|
31
|
+
if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
|
|
32
|
+
if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
|
|
33
|
+
return answer['allow'] === true ? { decision: 'allow' } : below
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
return hookOn('PermissionRequest', async (input) => {
|
|
37
|
+
const payload = processPayloadOf('PermissionRequest', input)
|
|
38
|
+
if (!isBefore) {
|
|
39
|
+
void raise(method, payload)
|
|
40
|
+
return undefined
|
|
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
|
+
})
|
|
47
|
+
}
|
package/src/runtime/sessions.ts
CHANGED
|
@@ -12,12 +12,13 @@ import { GoalChange, type Goal } from '../session/goal/goal.js'
|
|
|
12
12
|
import { TaskEnd, TaskStart, type Task } from '../session/goal/task.js'
|
|
13
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
14
|
import { matches, sentMatcher, type Matcher, type SentMatcher } from '../utils/matcher.js'
|
|
15
|
-
import { foldAnswers, type Answers, type Place } from './before.js'
|
|
15
|
+
import { beforeAnswerKeys, foldAnswers, foldBefore, methodOf, takenAnswer, type Answers, type Place } from './before.js'
|
|
16
16
|
import type { Claude } from './claude.js'
|
|
17
17
|
import { cmodPluginName } from './dependencies.js'
|
|
18
18
|
import { messageOf } from '../utils/text.js'
|
|
19
19
|
import { listenerOf, PassedTimeout, withinTime, type ListenOptions, type ListenTime } from './deadline.js'
|
|
20
|
-
import { callFiles, type
|
|
20
|
+
import { callFiles, type HomeFolders, type HookFields } from './hooks.js'
|
|
21
|
+
import type { PermissionRoutes } from './permission-check.js'
|
|
21
22
|
|
|
22
23
|
export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
|
|
23
24
|
|
|
@@ -50,8 +51,6 @@ const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
|
|
|
50
51
|
|
|
51
52
|
export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart ? 'tasks' : 'prompt')
|
|
52
53
|
|
|
53
|
-
const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
|
|
54
|
-
|
|
55
54
|
export const isAct = (action: string): action is Act => action === 'callTool' || action in actPermissions
|
|
56
55
|
|
|
57
56
|
export function toolPermission(tool: string): string | undefined {
|
|
@@ -134,7 +133,9 @@ export function isInScope(scope: Scope, session: string, change: SessionChange,
|
|
|
134
133
|
return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
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 {
|
|
138
139
|
const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
|
|
139
140
|
const listening = new Map<string, Set<Listening>>()
|
|
140
141
|
const deciding = new Map<string, Set<Listening>>()
|
|
@@ -197,6 +198,13 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
197
198
|
|
|
198
199
|
|
|
199
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
|
+
|
|
200
208
|
const combined = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<Partial<Answers> | undefined> => {
|
|
201
209
|
const askers = [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])]
|
|
202
210
|
.filter((entry) => entry.event === event)
|
|
@@ -204,15 +212,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
204
212
|
if (!isInScope(entry.scope, session, latest, isUnderIn(session))) return undefined
|
|
205
213
|
if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
|
|
206
214
|
const payload = await payloadFor(session, latest)
|
|
207
|
-
const answer = await
|
|
208
|
-
if (entry.options.onFailure !== 'refuse') throw error
|
|
209
|
-
report(`session:${entry.method}`, `The ${entry.method} listener failed`, error)
|
|
210
|
-
return { refuse: `${name}: ${messageOf(error)}` }
|
|
211
|
-
})
|
|
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])
|
|
212
216
|
if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
|
|
213
217
|
throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
|
|
214
218
|
})
|
|
215
|
-
return
|
|
219
|
+
return foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
|
|
216
220
|
}
|
|
217
221
|
|
|
218
222
|
const eventMethods = (scopeOf: () => Scope, hasBefore: boolean, ended?: string): OnMethods & Partial<BeforeMethods> => {
|
|
@@ -226,9 +230,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
226
230
|
]) as OnMethods & Partial<BeforeMethods>
|
|
227
231
|
}
|
|
228
232
|
|
|
233
|
+
let folders: Promise<HomeFolders> | undefined
|
|
229
234
|
const withFiles = async (message: Message): Promise<Message> => {
|
|
230
235
|
if (message.type !== ToolCall && message.type !== ToolResult) return message
|
|
231
|
-
|
|
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) }
|
|
232
238
|
}
|
|
233
239
|
|
|
234
240
|
const messagesFrom = async (method: string, input: unknown) => Promise.all((await call<Message[]>(method, input)).map(withFiles))
|
|
@@ -426,39 +432,17 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
426
432
|
const processListeners = new Map<string, Set<ProcessListener>>()
|
|
427
433
|
|
|
428
434
|
const raise = async (method: string, payload: HookFields): Promise<HookFields | undefined> => {
|
|
429
|
-
const isBefore = method.startsWith('before')
|
|
430
435
|
const heard = [...(processListeners.get(method) ?? [])].filter(({ matcher }) => payload['task'] === undefined && (matcher === undefined || matches(payload, matcher)))
|
|
431
|
-
if (!
|
|
432
|
-
await Promise.all(
|
|
433
|
-
heard.map(({ listener, options }) =>
|
|
434
|
-
withinTime(claude.clock, method, options, (time) => listener(payload, time)).catch((error: unknown) => {
|
|
435
|
-
if (!(error instanceof PassedTimeout)) report(`session:${method}`, `The ${method} listener failed`, error)
|
|
436
|
-
}),
|
|
437
|
-
),
|
|
438
|
-
)
|
|
436
|
+
if (!method.startsWith('before')) {
|
|
437
|
+
await Promise.all(heard.map(({ listener, options }) => invoke(method, options, (time) => listener(payload, time))))
|
|
439
438
|
return undefined
|
|
440
439
|
}
|
|
440
|
+
const failing = method === 'beforePermissionRequest' ? { onFailure: 'refuse' as const } : {}
|
|
441
441
|
const takes = answerKeys[method.slice('before'.length)] ?? []
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
try {
|
|
445
|
-
const answer = (await withinTime(claude.clock, method, options, (time) => listener(payload, time))) as HookFields | null | undefined
|
|
446
|
-
if (answer === undefined || answer === null) continue
|
|
447
|
-
const unread = Object.keys(answer).filter((key) => !takes.includes(key))
|
|
448
|
-
if (unread.length > 0) throw new Error(`it answered ${unread.join(' and ')}, which ${method} does not take. It takes ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
|
|
449
|
-
const notes = [merged?.['note'], answer['note']].filter((note): note is string => typeof note === 'string')
|
|
450
|
-
merged = { ...merged, ...answer, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
|
|
451
|
-
if (typeof answer['refuse'] === 'string') return merged
|
|
452
|
-
} catch (error) {
|
|
453
|
-
report(`session:${method}`, `The ${method} listener failed`, error)
|
|
454
|
-
if (options.onFailure === 'refuse' || method === 'beforePermissionRequest') return { refuse: `${name}: ${messageOf(error)}` }
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
return merged
|
|
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))
|
|
458
444
|
}
|
|
459
445
|
|
|
460
|
-
const permissionCheck = (e: ToolCheck, below: ToolCheckVerdict): HookFields => ({ session: current.id, cwd: current.cwd, ...defined({ task: e.agentId, callId: e.tool_use_id, reason: below.reason, rule: below.rule, ceiling: e.ceiling === undefined ? undefined : verdictName(e.ceiling) }), name: e.tool, input: recordOf(e.input), decision: verdictName(below.decision) })
|
|
461
|
-
|
|
462
446
|
const commandRun = async (e: Args<'command.run'>): Promise<HookFields> => {
|
|
463
447
|
const source = (await claude.command.list()).find((command) => command.name === e.command)?.source
|
|
464
448
|
return { session: current.id, cwd: current.cwd, name: e.command, args: e.args, ...defined({ source }), from: senderOf(e.origin, false) }
|
|
@@ -483,30 +467,21 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
483
467
|
const listenTo = (method: string, named: string) => {
|
|
484
468
|
if (hookOn === undefined || routeOn === undefined) throw new Error(`${name}: ${method} works only in a mod's own session.`)
|
|
485
469
|
const isBefore = method.startsWith('before')
|
|
486
|
-
if (named === 'PermissionCheck') {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
void raise(method, permissionCheck(e, below))
|
|
491
|
-
return below
|
|
492
|
-
}
|
|
493
|
-
const answer = await raise(method, permissionCheck(e, below))
|
|
494
|
-
if (answer === undefined || below.decision === 'deny') return below
|
|
495
|
-
if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
|
|
496
|
-
if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
|
|
497
|
-
return answer['allow'] === true ? { decision: 'allow' } : below
|
|
498
|
-
})
|
|
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)
|
|
499
474
|
}
|
|
500
475
|
const event: { readonly on: string; readonly before?: string } = processEvents[named as ProcessName]
|
|
501
476
|
const hook = (isBefore ? event.before : undefined) ?? event.on
|
|
502
477
|
return hookOn(hook, async (input: unknown) => {
|
|
503
|
-
const payload = processPayloadOf(named as
|
|
478
|
+
const payload = processPayloadOf(named as ProcessFieldsName, input)
|
|
504
479
|
if (!isBefore) {
|
|
505
480
|
if (named !== 'SessionStart' || payload['source'] !== 'compact') void raise(method, payload)
|
|
506
481
|
return undefined
|
|
507
482
|
}
|
|
508
483
|
const answer = await raise(method, payload)
|
|
509
|
-
return answer === undefined ? undefined : hookAnswerOf(named,
|
|
484
|
+
return answer === undefined ? undefined : hookAnswerOf(named, answer)
|
|
510
485
|
})
|
|
511
486
|
}
|
|
512
487
|
|
|
@@ -550,14 +525,14 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
550
525
|
},
|
|
551
526
|
changed(session, change, isInProject) {
|
|
552
527
|
remember(session, change)
|
|
528
|
+
let payload: Promise<unknown> | undefined
|
|
553
529
|
for (const key of isInProject ? [session, everySession] : [session]) {
|
|
554
530
|
for (const { scope, event, method, matcher, listener, options } of listening.get(key) ?? []) {
|
|
555
531
|
if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session))) continue
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
.
|
|
559
|
-
|
|
560
|
-
})
|
|
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))
|
|
561
536
|
}
|
|
562
537
|
}
|
|
563
538
|
},
|
|
@@ -583,22 +558,16 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
583
558
|
}
|
|
584
559
|
}
|
|
585
560
|
|
|
586
|
-
type
|
|
587
|
-
|
|
588
|
-
type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
|
|
589
|
-
|
|
590
|
-
type ToolCheckVerdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?: string; readonly rule?: string }
|
|
561
|
+
export type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
|
|
591
562
|
|
|
592
563
|
export type RouteOn = {
|
|
593
564
|
(event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>): Disposable
|
|
594
565
|
(event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>): Disposable
|
|
595
566
|
}
|
|
596
567
|
|
|
597
|
-
const
|
|
568
|
+
export const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
|
|
598
569
|
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
const defined = (fields: HookFields): HookFields => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
|
|
570
|
+
export const defined = (fields: HookFields): HookFields => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
|
|
602
571
|
|
|
603
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
|
|
604
573
|
|
|
@@ -635,7 +604,9 @@ const answerKeys: Readonly<Record<string, readonly string[]>> = {
|
|
|
635
604
|
|
|
636
605
|
const todoOf = (input: HookFields) => ({ id: input['task_id'], subject: input['task_subject'], description: input['task_description'], teammate: input['teammate_name'] })
|
|
637
606
|
|
|
638
|
-
|
|
607
|
+
type ProcessFieldsName = Exclude<ProcessName, 'PermissionCheck'>
|
|
608
|
+
|
|
609
|
+
const processFields: { readonly [Name in ProcessFieldsName]: (input: HookFields) => HookFields } = {
|
|
639
610
|
ProjectSetup: (input) => ({ trigger: input['trigger'] }),
|
|
640
611
|
SessionStart: (input) => ({
|
|
641
612
|
source: input['source'],
|
|
@@ -683,28 +654,24 @@ export async function spawnTask(claude: Pick<Claude, 'agent'>, task: TaskStartAr
|
|
|
683
654
|
return { id: started.agentId, title: task.title, agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() }
|
|
684
655
|
}
|
|
685
656
|
|
|
686
|
-
export function processPayloadOf(named:
|
|
657
|
+
export function processPayloadOf(named: ProcessFieldsName, input: unknown): HookFields {
|
|
687
658
|
const fields = recordOf(input)
|
|
688
659
|
return { ...placementOf(fields), ...defined(processFields[named](fields)) }
|
|
689
660
|
}
|
|
690
661
|
|
|
691
|
-
|
|
692
|
-
const output = (fields: HookFields): HookAnswer => ({ hookSpecificOutput: { hookEventName: hook as Hook, ...defined(fields) } })
|
|
662
|
+
function hookAnswerOf(named: string, answer: HookFields): HookFields | undefined {
|
|
693
663
|
const refuse = typeof answer['refuse'] === 'string' ? answer['refuse'] : undefined
|
|
694
|
-
|
|
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'] })
|
|
695
666
|
if (named === 'ModelChange') {
|
|
696
|
-
if (refuse !== undefined) return
|
|
697
|
-
return typeof answer['ask'] === 'string' ?
|
|
698
|
-
}
|
|
699
|
-
if (named === 'WorktreeCreate') return output({ worktreePath: answer['result'] })
|
|
700
|
-
if (named === 'PermissionRequest') {
|
|
701
|
-
if (refuse !== undefined) return output({ decision: defined({ behavior: 'deny', message: refuse, interrupt: answer['interrupt'] }) })
|
|
702
|
-
return answer['allow'] === true ? output({ decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) }) : undefined
|
|
667
|
+
if (refuse !== undefined) return { permissionDecision: 'deny', permissionDecisionReason: refuse }
|
|
668
|
+
return typeof answer['ask'] === 'string' ? { permissionDecision: 'ask', permissionDecisionReason: answer['ask'] } : undefined
|
|
703
669
|
}
|
|
704
|
-
if (named === '
|
|
705
|
-
if (named === '
|
|
706
|
-
if (named === '
|
|
707
|
-
|
|
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 }
|
|
708
675
|
}
|
|
709
676
|
|
|
710
677
|
export function resultOf(where: Pick<Place, 'session' | 'context' | 'turn'>, call: ToolCallArgs & { readonly cwd: string }, answered: Awaited<ReturnType<Claude['tool']['call']>>): ToolResult {
|
package/src/runtime/state.ts
CHANGED
|
@@ -84,14 +84,19 @@ export function createState<State extends object>({ name, initial, session, root
|
|
|
84
84
|
|
|
85
85
|
const forgetEndedSessions = async (current: string) => {
|
|
86
86
|
const stored = await claude.store.keys()
|
|
87
|
-
|
|
88
|
-
const owner = recorded.slice(transcriptsKey.length)
|
|
89
|
-
if (owner === current) continue
|
|
87
|
+
const ended = async (recorded: string) => {
|
|
90
88
|
const transcript = await claude.store.get(recorded)
|
|
91
|
-
|
|
92
|
-
for (const value of stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`))) await claude.store.delete(value)
|
|
93
|
-
await claude.store.delete(recorded)
|
|
89
|
+
return typeof transcript !== 'string' || !(await claude.fs.exists(transcript))
|
|
94
90
|
}
|
|
91
|
+
const owners = stored.filter((key) => key.startsWith(transcriptsKey) && key !== `${transcriptsKey}${current}`)
|
|
92
|
+
await Promise.all(
|
|
93
|
+
owners.map(async (recorded) => {
|
|
94
|
+
if (!(await ended(recorded))) return
|
|
95
|
+
const owner = recorded.slice(transcriptsKey.length)
|
|
96
|
+
await Promise.all(stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`)).map((value) => claude.store.delete(value)))
|
|
97
|
+
await claude.store.delete(recorded)
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
const loadGroups = async (only: readonly Lifetime[], next: { readonly root: string; readonly session: string }) => {
|
|
@@ -151,8 +156,10 @@ export function createState<State extends object>({ name, initial, session, root
|
|
|
151
156
|
moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId }))),
|
|
152
157
|
switchSession: (nextSession, source, transcript) =>
|
|
153
158
|
inTurn(async () => {
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
if (Object.keys(declared.session).length > 0) {
|
|
160
|
+
await claude.store.set(`${transcriptsKey}${nextSession}`, transcript)
|
|
161
|
+
void forgetEndedSessions(nextSession).catch((error: unknown) => report({ id: 'state:sessions', message: 'Could not forget the session values of ended sessions', error }))
|
|
162
|
+
}
|
|
156
163
|
if (nextSession === sessionId) return
|
|
157
164
|
if (source === 'fork') await copySession(sessionId, nextSession)
|
|
158
165
|
return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession })
|
|
@@ -50,7 +50,11 @@ export const skillPrefix = 'Base directory for this skill:'
|
|
|
50
50
|
|
|
51
51
|
const rowTypes: readonly string[] = ['user', 'assistant', 'attachment', 'system']
|
|
52
52
|
|
|
53
|
-
const memoryPath = /^Contents of (.+?) \(
|
|
53
|
+
const memoryPath = /^Contents of (.+?)(?: \([^)\n]*\))?:\r?$/m
|
|
54
|
+
|
|
55
|
+
export function instructionPathOf(text: string): string {
|
|
56
|
+
return memoryPath.exec(text)?.[1] ?? ''
|
|
57
|
+
}
|
|
54
58
|
|
|
55
59
|
export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefined {
|
|
56
60
|
const { uuid, timestamp, type } = row
|
|
@@ -77,7 +81,7 @@ export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefin
|
|
|
77
81
|
export function rowOfAppend(append: { readonly uuid: string; readonly door: string; readonly origin: unknown; readonly message: { readonly type: Row['type']; readonly name?: string; readonly isMeta?: true; readonly content: readonly unknown[] } }, at: string, cwd: string): Row {
|
|
78
82
|
const { message, origin } = append
|
|
79
83
|
const text = textOf(message.content)
|
|
80
|
-
const path = message.type === 'attachment' ?
|
|
84
|
+
const path = message.type === 'attachment' ? instructionPathOf(text) || undefined : undefined
|
|
81
85
|
const model = isObject(origin) && origin['kind'] === 'model' && typeof origin['model'] === 'string' ? origin['model'] : undefined
|
|
82
86
|
return {
|
|
83
87
|
id: append.uuid,
|
package/src/testing.ts
CHANGED
|
@@ -23,7 +23,8 @@ import type { Options, OptionValues } from './options.js'
|
|
|
23
23
|
import { readSteps, storeFolder, type Steps } from './records.js'
|
|
24
24
|
import type { Claude } from './runtime/claude.js'
|
|
25
25
|
import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
|
|
26
|
-
import {
|
|
26
|
+
import { foldBefore, installBefore } from './runtime/before.js'
|
|
27
|
+
import { permissionRoutes } from './runtime/permission-check.js'
|
|
27
28
|
import type { RoutedEvent } from './runtime/hooks.js'
|
|
28
29
|
import { keepClaudeCode, type ClaudeCode } from './testing/claude-code.js'
|
|
29
30
|
import { createRouter } from './runtime/router.js'
|
|
@@ -174,7 +175,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
174
175
|
const starting = options.state as Record<string, object> | undefined
|
|
175
176
|
const declared = Object.entries(definition.state ?? {}) as [string, object][]
|
|
176
177
|
const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }])) as State
|
|
177
|
-
const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () =>
|
|
178
|
+
const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () => permissionRoutes,(options.options ?? {}) as PluginOptions, false)
|
|
178
179
|
let started: Promise<void> | undefined
|
|
179
180
|
|
|
180
181
|
const start = async () => {
|
|
@@ -212,7 +213,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
212
213
|
hooks: {
|
|
213
214
|
place: async () => conversations.place(),
|
|
214
215
|
wants: async (event) => conversations.wants(name, event),
|
|
215
|
-
decide: (event, change) =>
|
|
216
|
+
decide: (event, change) => foldBefore(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
|
|
216
217
|
},
|
|
217
218
|
})
|
|
218
219
|
|