@cmodjs/core 1.6.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +7 -7
- package/docs/dependencies.md +1 -1
- package/docs/index.md +12 -11
- package/docs/jobs.md +12 -16
- package/docs/mod.md +111 -36
- package/docs/permissions.md +14 -18
- package/docs/session.md +171 -82
- package/docs/state.md +5 -5
- package/docs/testing.md +57 -41
- package/docs/ui.md +9 -9
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +1 -1
- package/jobs/prompt.d.ts +1 -3
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +2 -21
- package/jobs/slash-command.d.ts +1 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +1 -1
- package/jobs/status-line.d.ts +0 -1
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +3 -4
- package/mod.d.ts +53 -105
- package/mod.d.ts.map +1 -1
- package/mod.js +18 -80
- package/package.json +1 -1
- package/records.d.ts +1 -1
- package/records.js +1 -1
- package/register.d.ts.map +1 -1
- package/register.js +8 -5
- package/runtime/before.d.ts +11 -4
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +131 -67
- package/runtime/claude.d.ts +6 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +82 -38
- package/runtime/deadline.d.ts +17 -0
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +34 -0
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +12 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +13 -8
- package/runtime/hooks.d.ts +15 -11
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +8 -165
- package/runtime/lifecycle.d.ts +4 -2
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +96 -62
- package/runtime/permission-check.d.ts +15 -0
- package/runtime/permission-check.d.ts.map +1 -0
- package/runtime/permission-check.js +37 -0
- package/runtime/sessions.d.ts +35 -5
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +304 -82
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +14 -11
- package/runtime/tool-calls.d.ts +0 -1
- package/runtime/tool-calls.d.ts.map +1 -1
- package/runtime/tool-calls.js +0 -19
- package/session/context/turn/message/instruction-file.d.ts +9 -0
- package/session/context/turn/message/instruction-file.d.ts.map +1 -0
- package/session/context/turn/message/instruction-file.js +4 -0
- package/session/context/turn/message/message.d.ts +6 -4
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +12 -8
- package/session/context/turn/message/sender.d.ts +2 -1
- package/session/context/turn/message/sender.d.ts.map +1 -1
- package/session/context/turn/message/sender.js +4 -1
- package/session/context/turn/message/tool-call.d.ts +5 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -1
- package/session/context/turn/message/tool-result.d.ts +6 -1
- package/session/context/turn/message/tool-result.d.ts.map +1 -1
- package/session/context/turn/turn.d.ts +7 -0
- package/session/context/turn/turn.d.ts.map +1 -1
- package/session/conversation.d.ts +10 -3
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +14 -5
- package/session/goal/goal.d.ts +1 -1
- package/session/goal/goal.d.ts.map +1 -1
- package/session/goal/task.d.ts +2 -2
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +361 -26
- package/session/session.d.ts.map +1 -1
- package/session/session.js +40 -0
- package/src/jobs/permissions/decide-permission.ts +2 -1
- package/src/jobs/prompt.ts +3 -20
- package/src/jobs/slash-command.ts +2 -2
- package/src/jobs/status-line.ts +4 -7
- package/src/mod.ts +52 -147
- package/src/records.ts +1 -1
- package/src/register.ts +8 -5
- package/src/runtime/before.ts +140 -66
- package/src/runtime/claude.ts +5 -1
- package/src/runtime/conversations.ts +75 -37
- package/src/runtime/deadline.ts +38 -0
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/grants.ts +11 -7
- package/src/runtime/hooks.ts +24 -163
- package/src/runtime/lifecycle.ts +110 -77
- package/src/runtime/permission-check.ts +47 -0
- package/src/runtime/sessions.ts +334 -91
- package/src/runtime/state.ts +15 -8
- package/src/runtime/tool-calls.ts +0 -19
- package/src/session/context/turn/message/instruction-file.ts +13 -0
- package/src/session/context/turn/message/message.ts +17 -11
- package/src/session/context/turn/message/sender.ts +5 -2
- package/src/session/context/turn/message/tool-call.ts +3 -0
- package/src/session/context/turn/message/tool-result.ts +6 -1
- package/src/session/context/turn/turn.ts +4 -0
- package/src/session/conversation.ts +21 -8
- package/src/session/goal/goal.ts +1 -1
- package/src/session/goal/task.ts +2 -2
- package/src/session/session.ts +227 -24
- package/src/testing/claude-code.ts +23 -0
- package/src/testing/fake-claude.ts +5 -1
- package/src/testing/fake-sessions.ts +38 -24
- package/src/testing.ts +88 -19
- package/testing/claude-code.d.ts +12 -0
- package/testing/claude-code.d.ts.map +1 -0
- package/testing/claude-code.js +10 -0
- package/testing/fake-claude.d.ts +4 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +4 -1
- package/testing/fake-sessions.d.ts +8 -6
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +35 -20
- package/testing.d.ts +38 -9
- package/testing.d.ts.map +1 -1
- package/testing.js +58 -8
- package/docs/hooks.md +0 -259
- package/session/context/turn/message/document.d.ts +0 -7
- package/session/context/turn/message/document.d.ts.map +0 -1
- package/session/context/turn/message/document.js +0 -1
- package/src/session/context/turn/message/document.ts +0 -8
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement, Timer } from 'claude-code'
|
|
2
2
|
import type { Step } from '../installer.js'
|
|
3
|
-
import {
|
|
3
|
+
import type { Allowed, FileChange, Disposable, Imports, Mod, ModDefinition, PaneHandle, SettingsFile, SettingsFileChange, SettingsFileChangeListener, Toast } from '../mod.js'
|
|
4
4
|
import type { Options, OptionValues } from '../options.js'
|
|
5
5
|
import { changelogPage, consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
|
|
6
6
|
import { homePrefix, ifMissing, relativePath } from '../utils/paths.js'
|
|
@@ -10,9 +10,10 @@ import { beforeDeadline } from './deadline.js'
|
|
|
10
10
|
import { announcedOf, announcementMs, announcementOf } from './announce.js'
|
|
11
11
|
import { bind } from './bound.js'
|
|
12
12
|
import { serveConversations } from './conversations.js'
|
|
13
|
-
import { answerCall, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
|
|
13
|
+
import { answerCall, answeringOwnCalls, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
|
|
14
14
|
import { keepEngine } from './engine.js'
|
|
15
|
-
import { classicHook,
|
|
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'
|
|
@@ -51,6 +52,7 @@ export type Lifecycle<State extends object> = {
|
|
|
51
52
|
readonly toasts: readonly ShownToast[]
|
|
52
53
|
start(claude: Claude, read: (claude: Claude) => Promise<Plugin>): Promise<void>
|
|
53
54
|
route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
55
|
+
raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
type ModRuntime = {
|
|
@@ -62,7 +64,7 @@ type ModRuntime = {
|
|
|
62
64
|
readonly steps: Steps
|
|
63
65
|
readonly granted: ReadonlySet<string>
|
|
64
66
|
readonly refreshGrants: () => Promise<void>
|
|
65
|
-
readonly
|
|
67
|
+
readonly permissionRoutes: () => PermissionRoutes | undefined
|
|
66
68
|
readonly options: PluginOptions
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -70,6 +72,7 @@ type ActiveMod<State extends object, Declared extends Options> = {
|
|
|
70
72
|
readonly mod: Mod<State, Declared>
|
|
71
73
|
readonly added: readonly string[]
|
|
72
74
|
readonly imports: Imports
|
|
75
|
+
readonly raise: (method: string, payload: Readonly<Record<string, unknown>>) => Promise<unknown>
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
|
|
@@ -83,6 +86,8 @@ const cmodCheckMs = 1000
|
|
|
83
86
|
|
|
84
87
|
const cmodWaitMs = 60_000
|
|
85
88
|
|
|
89
|
+
const cmodSlowCheckMs = 10_000
|
|
90
|
+
|
|
86
91
|
const startHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
|
|
87
92
|
|
|
88
93
|
const dependencyCallMs = 30_000
|
|
@@ -151,7 +156,7 @@ async function cmodVersion(claude: Claude): Promise<string | undefined> {
|
|
|
151
156
|
|
|
152
157
|
export function createLifecycle<State extends object, Declared extends Options = Options>(
|
|
153
158
|
definition: ModDefinition<State, string, Declared>,
|
|
154
|
-
|
|
159
|
+
permissionRoutes: () => PermissionRoutes | undefined = () => undefined,
|
|
155
160
|
options: PluginOptions = {},
|
|
156
161
|
asksPerson = true,
|
|
157
162
|
): Lifecycle<State> {
|
|
@@ -164,6 +169,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
164
169
|
let activation: Promise<void> | undefined
|
|
165
170
|
let mod: Mod<State> | undefined
|
|
166
171
|
let imports: Imports | undefined
|
|
172
|
+
let raise: ActiveMod<State, Declared>['raise'] | undefined
|
|
167
173
|
let failure: unknown
|
|
168
174
|
let pluginOptions = options
|
|
169
175
|
const installer = createInstaller(definition.name, () => claude())
|
|
@@ -316,7 +322,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
316
322
|
const activate = async (): Promise<void> => {
|
|
317
323
|
if (runtime === undefined || plugin === undefined) return
|
|
318
324
|
const activeRouter = createRouter()
|
|
319
|
-
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) => {
|
|
320
326
|
if (error instanceof MissingOptions && (await askOptions(error))) return undefined
|
|
321
327
|
fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : undefined, begin)
|
|
322
328
|
throw error
|
|
@@ -325,18 +331,25 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
325
331
|
const { added } = active
|
|
326
332
|
mod = active.mod
|
|
327
333
|
imports = active.imports
|
|
334
|
+
raise = active.raise
|
|
328
335
|
router = activeRouter
|
|
329
336
|
phase = 'active'
|
|
330
337
|
runtime.claude.ui.invalidate('ui.render')
|
|
331
338
|
void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
|
|
332
339
|
const missed = missedSessionStart
|
|
333
340
|
missedSessionStart = undefined
|
|
334
|
-
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) => {
|
|
335
343
|
toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error })
|
|
336
344
|
return {}
|
|
337
345
|
})
|
|
338
|
-
|
|
339
|
-
|
|
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' })
|
|
340
353
|
}
|
|
341
354
|
const version = plugin.version ?? ''
|
|
342
355
|
const announced = announcedOf(await runtime.claude.store.get(announcedKey))
|
|
@@ -418,27 +431,31 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
418
431
|
timer.cancel()
|
|
419
432
|
longWait.cancel()
|
|
420
433
|
}
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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)
|
|
436
453
|
installing(
|
|
437
454
|
latest === undefined
|
|
438
455
|
? "Still waiting for cmod to download cmod. See cmod's own line."
|
|
439
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.`,
|
|
440
|
-
)
|
|
441
|
-
)
|
|
457
|
+
)
|
|
458
|
+
})
|
|
442
459
|
}
|
|
443
460
|
|
|
444
461
|
const bootstrap = async (root: string) => {
|
|
@@ -560,6 +577,11 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
560
577
|
get toasts() {
|
|
561
578
|
return runtime?.toaster.shown ?? []
|
|
562
579
|
},
|
|
580
|
+
async raise(method, payload) {
|
|
581
|
+
if (raise === undefined || imports === undefined) throw new Error(`${definition.name} has not started, so ${method} has no listener yet.`)
|
|
582
|
+
bind(imports)
|
|
583
|
+
return raise(method, payload)
|
|
584
|
+
},
|
|
563
585
|
async start(claudeCalls, read) {
|
|
564
586
|
if (runtime !== undefined) return
|
|
565
587
|
programs = locatingPrograms(claudeCalls)
|
|
@@ -586,7 +608,10 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
586
608
|
const isCallToThisMod = event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name
|
|
587
609
|
if (event === 'cmod.call' && !isCallToThisMod) return next(e)
|
|
588
610
|
await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
|
|
589
|
-
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
|
+
}
|
|
590
615
|
if (isCallToThisMod && phase !== 'active') {
|
|
591
616
|
const isStopped = phase === 'declined' || phase === 'failed'
|
|
592
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>
|
|
@@ -609,9 +634,8 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
609
634
|
async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
|
|
610
635
|
const { router, toaster } = runtime
|
|
611
636
|
const report = (failure: Failure) => void toaster.fail(failure)
|
|
612
|
-
const claude = withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {}))
|
|
637
|
+
const claude = answeringOwnCalls(withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {})), router)
|
|
613
638
|
const added: string[] = []
|
|
614
|
-
const hooksAdded: Hook[] = []
|
|
615
639
|
const announce = (feature: string) => {
|
|
616
640
|
added.push(feature)
|
|
617
641
|
}
|
|
@@ -680,7 +704,7 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
680
704
|
}))
|
|
681
705
|
const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
|
|
682
706
|
const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
|
|
683
|
-
const
|
|
707
|
+
const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>): Disposable => router.add(`classic.${hook}`, classicHook(hook, listener, report))
|
|
684
708
|
const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
|
|
685
709
|
const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
|
|
686
710
|
read: () => checked.settings.read({ source }),
|
|
@@ -689,8 +713,18 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
689
713
|
await claude.cmod.call({ to: cmodPluginName, method: 'updateSettings', input: { mod: definition.name, source, project: modState.root, values } })
|
|
690
714
|
},
|
|
691
715
|
})
|
|
692
|
-
const client = createSessionsClient({
|
|
716
|
+
const client = createSessionsClient({
|
|
717
|
+
name: definition.name,
|
|
718
|
+
claude: checked,
|
|
719
|
+
report: (id, message, error) => report({ id, message, error }),
|
|
720
|
+
isGranted: (permission) => flag(permission),
|
|
721
|
+
hookOn: (hook, listener) => hookOn(hook as Hook, listener as HookListener<Hook>),
|
|
722
|
+
routeOn: (event, hook) => router.add(event, hook as never),
|
|
723
|
+
permissionRoutes: runtime.permissionRoutes,
|
|
724
|
+
})
|
|
693
725
|
let followed: Parameters<typeof client.follow>[0] | undefined
|
|
726
|
+
let isSetUp = false
|
|
727
|
+
const watches = new Set<{ readonly paths: readonly string[]; readonly listener: (change: FileChange) => unknown }>()
|
|
694
728
|
const followFolders = (project: string, folder: string) => {
|
|
695
729
|
if (followed === undefined) return
|
|
696
730
|
followed = { ...followed, project, cwd: folder }
|
|
@@ -698,7 +732,16 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
698
732
|
}
|
|
699
733
|
const imports: Imports = {
|
|
700
734
|
sessions: client.sessions,
|
|
701
|
-
|
|
735
|
+
commands: {
|
|
736
|
+
list: async () => (await checked.command.list()).map(({ name, description, source, plugin }) => ({ name, description, source, ...(plugin === undefined ? {} : { plugin }) })),
|
|
737
|
+
run: async (command, args) => {
|
|
738
|
+
const { text, context } = await checked.command.run({ command, ...(args === undefined ? {} : { args }) })
|
|
739
|
+
return { ...(text === undefined ? {} : { text }), ...(context === undefined || context.length === 0 ? {} : { note: context.join('\n') }) }
|
|
740
|
+
},
|
|
741
|
+
beforeRun: (...args: unknown[]) => client.listen('beforeRun', args),
|
|
742
|
+
onRun: (...args: unknown[]) => client.listen('onRun', args),
|
|
743
|
+
},
|
|
744
|
+
config: {
|
|
702
745
|
list: async () =>
|
|
703
746
|
(await checked.config.list()).map(({ key, label, description, kind, value, options, provider, isLocked }) => ({
|
|
704
747
|
key,
|
|
@@ -710,9 +753,21 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
710
753
|
...(options === undefined ? {} : { options }),
|
|
711
754
|
...(provider.plugin === 'engine' ? {} : { plugin: provider.plugin }),
|
|
712
755
|
})),
|
|
756
|
+
},
|
|
757
|
+
settings: {
|
|
713
758
|
user: settingsFile('user'),
|
|
714
759
|
project: settingsFile('project'),
|
|
715
760
|
local: settingsFile('local'),
|
|
761
|
+
beforeFileChange: (matcherOrListener: unknown, maybeListener?: SettingsFileChangeListener) => {
|
|
762
|
+
const listener = (maybeListener ?? matcherOrListener) as SettingsFileChangeListener
|
|
763
|
+
const matcher = maybeListener === undefined ? undefined : matcherOrListener
|
|
764
|
+
return hookOn('ConfigChange', async ({ source, file_path }) => {
|
|
765
|
+
const change: SettingsFileChange = { source: source === 'skills' ? 'skills' : (source.replace('_settings', '') as SettingsFileChange['source']), ...(file_path === undefined ? {} : { path: file_path }) }
|
|
766
|
+
if (matcher !== undefined && !matches(change, matcher)) return undefined
|
|
767
|
+
const refuse = (await listener(change))?.refuse
|
|
768
|
+
return typeof refuse === 'string' ? { block: refuse } : undefined
|
|
769
|
+
})
|
|
770
|
+
},
|
|
716
771
|
},
|
|
717
772
|
model: { complete: (completion, options) => checked.model.complete(completion, options) },
|
|
718
773
|
fs: {
|
|
@@ -725,6 +780,20 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
725
780
|
const { kind, size, mtimeMs, isLink, realPath } = await checked.fs.stat(expand(path), options)
|
|
726
781
|
return { isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs, ...(realPath === undefined ? {} : { realPath }) }
|
|
727
782
|
},
|
|
783
|
+
onChange({ path }, listener) {
|
|
784
|
+
if (isSetUp) throw new Error(`${definition.name}: fs.onChange works only in setup, because Claude Code reads the paths it watches when a session starts.`)
|
|
785
|
+
const paths = [path].flat().map((each) => resolvedPath(each, places))
|
|
786
|
+
const watch = { paths, listener }
|
|
787
|
+
watches.add(watch)
|
|
788
|
+
if (watches.size === 1) {
|
|
789
|
+
hookOn('SessionStart', () => ({ watchPaths: [...new Set([...watches].flatMap((each) => each.paths))] }))
|
|
790
|
+
hookOn('FileChanged', ({ file_path, event }) => {
|
|
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
|
|
793
|
+
})
|
|
794
|
+
}
|
|
795
|
+
return { dispose: () => void watches.delete(watch) }
|
|
796
|
+
},
|
|
728
797
|
},
|
|
729
798
|
metadata: {
|
|
730
799
|
read: (path) => files.read(path),
|
|
@@ -787,23 +856,10 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
787
856
|
files: allowed('files'),
|
|
788
857
|
config: allowed('config'),
|
|
789
858
|
},
|
|
790
|
-
on(hook: Hook, matcherOrListener: unknown, listenerOrOptions?: unknown, maybeOptions?: HookOptions): Disposable {
|
|
791
|
-
const hasMatcher = typeof listenerOrOptions === 'function'
|
|
792
|
-
const listener = (hasMatcher ? listenerOrOptions : matcherOrListener) as HookListener<Hook>
|
|
793
|
-
const matcher = hasMatcher ? matcherOrListener : undefined
|
|
794
|
-
const options = (hasMatcher ? maybeOptions : listenerOrOptions) as HookOptions | undefined
|
|
795
|
-
if (!hooks.includes(hook)) throw new Error(`${definition.name}: mod.on takes one of Claude Code's hooks, such as PreToolUse, not ${String(hook)}.`)
|
|
796
|
-
const matched: HookListener<Hook> = matcher === undefined ? listener : (input) => (matches(input, matcher) ? listener(input) : undefined)
|
|
797
|
-
const bounded = options?.timeoutMs === undefined ? matched : timedHook(hook, matched, checkedMs(definition.name, `the ${hook} hook's timeoutMs`, options.timeoutMs), claude, toaster.toast)
|
|
798
|
-
if (!hooksAdded.includes(hook)) hooksAdded.push(hook)
|
|
799
|
-
if (hook === PreToolUse) return router.add('tool.call', preToolUseHook(definition.name, bounded as HookListener<typeof PreToolUse>, claude, agents, held))
|
|
800
|
-
const classic: Exclude<Hook, typeof PreToolUse> = hook
|
|
801
|
-
return router.add(`classic.${classic}`, classicHook(definition.name, classic, bounded as HookListener<typeof classic>, claude, agents, report))
|
|
802
|
-
},
|
|
803
859
|
every: (ms, listener) => timer(ms, 'every', claude.clock.every, listener),
|
|
804
860
|
after: (ms, listener) => timer(ms, 'after', claude.clock.after, listener),
|
|
805
861
|
use: (job) => job({ mod, announce, reserveName }),
|
|
806
|
-
log: (text) => claude.ui.log(`${definition.name}: ${text}`, { to: 'debug' }),
|
|
862
|
+
log: (text, options) => claude.ui.log(`${definition.name}: ${text}`, { to: options?.to ?? 'debug' }),
|
|
807
863
|
ui: { ...area.ui, notice: (callId, text) => claude.ui.notice(callId, text) },
|
|
808
864
|
settings: settingsPages,
|
|
809
865
|
dependencies: dependencyCalls(claude, (call, task) => beforeDeadline(claude, { ms: dependencyCallMs }, call, task)),
|
|
@@ -829,8 +885,8 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
829
885
|
loadedCwd = nextCwd
|
|
830
886
|
if (!hasMovedRoot) area.changed()
|
|
831
887
|
if (nextCwd === oldCwd) return
|
|
832
|
-
const moved
|
|
833
|
-
await router.dispatch('classic.CwdChanged', moved as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
|
|
888
|
+
const moved = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
|
|
889
|
+
await router.dispatch('classic.CwdChanged', moved as unknown as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
|
|
834
890
|
} catch (error) {
|
|
835
891
|
report({ id: 'cmod:follow', message: `Keeps the state of ${modState.root} until the next prompt or folder move`, error })
|
|
836
892
|
}
|
|
@@ -883,8 +939,8 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
883
939
|
if (e.to !== definition.name) return next(e)
|
|
884
940
|
const call = e as typeof e & { readonly caller?: string }
|
|
885
941
|
if (e.method === conversationMethods.changed && call.caller === cmodPluginName) {
|
|
886
|
-
if (!flag('conversation')) return { value: null }
|
|
887
942
|
const { session: changedSession, change, isInProject } = e.input as { readonly session: string; readonly change: SessionChange; readonly isInProject?: boolean }
|
|
943
|
+
if (changedSession !== client.sessions.current.id && !flag('conversation')) return { value: null }
|
|
888
944
|
client.changed(changedSession, change, isInProject !== false)
|
|
889
945
|
return { value: null }
|
|
890
946
|
}
|
|
@@ -915,20 +971,19 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
915
971
|
if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
|
|
916
972
|
await failsAs('the details of its session did not load', () => followCurrent(undefined))
|
|
917
973
|
await failsAs('its setup function threw', () => definition.setup(mod))
|
|
974
|
+
isSetUp = true
|
|
918
975
|
const permissionHooks = permissionEvents.filter((event) => router.has(event))
|
|
919
|
-
if (permissionHooks.length > 0 &&
|
|
976
|
+
if (permissionHooks.length > 0 && runtime.permissionRoutes() === undefined) {
|
|
920
977
|
throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
|
|
921
978
|
}
|
|
922
|
-
if (held !== undefined && hooksAdded.includes(PreToolUse)) router.add('tool.check', heldDecisionHook(held))
|
|
923
979
|
await failsAs('its open panes did not load', () => area.restorePanes())
|
|
924
|
-
|
|
925
|
-
return { mod, added, imports }
|
|
980
|
+
return { mod, added, imports, raise: client.raise }
|
|
926
981
|
}
|
|
927
982
|
|
|
928
983
|
function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
|
|
929
984
|
const bound = Object.entries(keys)
|
|
930
985
|
if (bound.length === 0) return claude
|
|
931
|
-
const
|
|
986
|
+
const addCommand: Claude['command']['register'] = (command) => {
|
|
932
987
|
for (const [key, commandName] of bound) {
|
|
933
988
|
if (commandName === command.name && command.immediate !== true) {
|
|
934
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 })
|
|
@@ -936,7 +991,7 @@ function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string
|
|
|
936
991
|
}
|
|
937
992
|
return claude.command.register(command)
|
|
938
993
|
}
|
|
939
|
-
return { ...claude, command: { ...claude.command, register } }
|
|
994
|
+
return { ...claude, command: { ...claude.command, register: addCommand } }
|
|
940
995
|
}
|
|
941
996
|
|
|
942
997
|
const longestTimerMs = 2_147_483_647
|
|
@@ -946,28 +1001,6 @@ function checkedMs(name: string, subject: string, ms: number): number {
|
|
|
946
1001
|
return ms
|
|
947
1002
|
}
|
|
948
1003
|
|
|
949
|
-
function timedHook<H extends Hook>(event: H, hook: HookListener<H>, ms: number, claude: Claude, toast: Toast): HookListener<H> {
|
|
950
|
-
return (input) =>
|
|
951
|
-
new Promise((resolve, reject) => {
|
|
952
|
-
const timer = claude.clock.after(ms, () => {
|
|
953
|
-
toast.warning(`The ${event} hook passed its ${ms / 1000} s timeout`, { id: `timeout:${event}` })
|
|
954
|
-
resolve(undefined)
|
|
955
|
-
})
|
|
956
|
-
Promise.resolve()
|
|
957
|
-
.then(() => hook(input))
|
|
958
|
-
.then(
|
|
959
|
-
(answer) => {
|
|
960
|
-
timer.cancel()
|
|
961
|
-
resolve(answer)
|
|
962
|
-
},
|
|
963
|
-
(error: unknown) => {
|
|
964
|
-
timer.cancel()
|
|
965
|
-
reject(error)
|
|
966
|
-
},
|
|
967
|
-
)
|
|
968
|
-
})
|
|
969
|
-
}
|
|
970
|
-
|
|
971
1004
|
async function failsAs(subject: string, step: () => unknown): Promise<void> {
|
|
972
1005
|
try {
|
|
973
1006
|
await step()
|
|
@@ -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
|
+
}
|