@cmodjs/core 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +4 -4
- package/docs/dependencies.md +1 -1
- package/docs/jobs.md +1 -1
- package/docs/mod.md +2 -3
- package/docs/permissions.md +1 -1
- package/docs/session.md +3 -1
- package/docs/testing.md +2 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +4 -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 +8 -4
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +63 -39
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +38 -13
- package/runtime/engine.d.ts +1 -0
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/hooks.d.ts +9 -23
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +4 -59
- package/runtime/lifecycle.d.ts +2 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +36 -23
- 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/router.d.ts +0 -1
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +0 -1
- package/runtime/sessions.d.ts +13 -8
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +44 -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/jobs/permissions.ts +3 -1
- package/src/records.ts +1 -1
- package/src/register.ts +5 -4
- package/src/runtime/before.ts +66 -42
- package/src/runtime/conversations.ts +35 -13
- package/src/runtime/engine.ts +1 -0
- package/src/runtime/hooks.ts +13 -86
- package/src/runtime/lifecycle.ts +51 -37
- package/src/runtime/permission-check.ts +47 -0
- package/src/runtime/router.ts +0 -2
- package/src/runtime/sessions.ts +55 -84
- package/src/runtime/state.ts +15 -8
- package/src/session/context/turn/message/message.ts +6 -2
- package/src/testing.ts +17 -10
- package/testing.d.ts +7 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +13 -7
|
@@ -9,7 +9,7 @@ import { GoalChange } from '../session/goal/goal.js'
|
|
|
9
9
|
import { receivedMatcher } from '../utils/matcher.js'
|
|
10
10
|
import { ifMissing } from '../utils/paths.js'
|
|
11
11
|
import { formatExit, messageOf } from '../utils/text.js'
|
|
12
|
-
import {
|
|
12
|
+
import { foldBefore, installBefore, relayOf, type Place } from './before.js'
|
|
13
13
|
import type { Claude } from './claude.js'
|
|
14
14
|
import { cmodPluginName } from './dependencies.js'
|
|
15
15
|
import { readLines, runCmod } from './programs.js'
|
|
@@ -80,7 +80,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
80
80
|
router.add('classic.StopFailure', async (e, next) => endTurn({ reason: 'error', failure: { type: String(e.error), text: e.error_details ?? e.last_assistant_message ?? String(e.error) } })(await next(e)))
|
|
81
81
|
router.add('classic.SubagentStop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) }, e.agent_id)(await next(e)))
|
|
82
82
|
|
|
83
|
-
const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()],
|
|
83
|
+
const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], await claude.session.id())) as Value
|
|
84
84
|
|
|
85
85
|
const isGranted = async (caller: string | undefined, item: string) => {
|
|
86
86
|
if (caller === cmodPluginName) return true
|
|
@@ -90,6 +90,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
90
90
|
return (parseConsent(JSON.parse(text), path)[caller] ?? []).includes(item)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
|
|
93
94
|
const ended = (id: string) => new Error(`session ${id} has ended`)
|
|
94
95
|
|
|
95
96
|
const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
|
|
@@ -144,17 +145,36 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
144
145
|
if (sent.isDelivered !== true) throw ended(to)
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
const waitingOn = new Map<Asked, string>()
|
|
149
|
+
let liveness: { cancel(): void } | undefined
|
|
150
|
+
let isChecking = false
|
|
151
|
+
const checkLiveness = async () => {
|
|
152
|
+
if (isChecking) return
|
|
153
|
+
isChecking = true
|
|
154
|
+
try {
|
|
155
|
+
const waiting = [...waitingOn]
|
|
156
|
+
const running = new Set((await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).map((each) => each.id))
|
|
157
|
+
for (const [settle, to] of waiting) if (!running.has(to)) settle.reject(ended(to))
|
|
158
|
+
} finally {
|
|
159
|
+
isChecking = false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const watchLife = (settle: Asked, to: string) => {
|
|
163
|
+
waitingOn.set(settle, to)
|
|
164
|
+
liveness ??= claude.clock.every(livenessMs, () => void checkLiveness().catch((error: unknown) => report('liveness', 'Could not check which sessions still run', error)))
|
|
165
|
+
return {
|
|
166
|
+
cancel() {
|
|
167
|
+
waitingOn.delete(settle)
|
|
168
|
+
if (waitingOn.size > 0) return
|
|
169
|
+
liveness?.cancel()
|
|
170
|
+
liveness = undefined
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
147
175
|
const ask = (to: string, relay: Question, deadlineMs?: number) =>
|
|
148
176
|
new Promise<unknown>((resolve, reject) => {
|
|
149
177
|
const id = `${session}:${(asks += 1)}`
|
|
150
|
-
const timer =
|
|
151
|
-
deadlineMs === undefined
|
|
152
|
-
? claude.clock.every(livenessMs, () => {
|
|
153
|
-
void isRunning(to).then((isLive) => {
|
|
154
|
-
if (!isLive) settle.reject(ended(to))
|
|
155
|
-
})
|
|
156
|
-
})
|
|
157
|
-
: claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
|
|
158
178
|
const settle: Asked = {
|
|
159
179
|
resolve(value) {
|
|
160
180
|
if (asked.delete(id)) timer.cancel()
|
|
@@ -165,6 +185,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
165
185
|
reject(error)
|
|
166
186
|
},
|
|
167
187
|
}
|
|
188
|
+
const timer = deadlineMs === undefined ? watchLife(settle, to) : claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
|
|
168
189
|
asked.set(id, settle)
|
|
169
190
|
post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
|
|
170
191
|
})
|
|
@@ -244,7 +265,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
244
265
|
return deciders(event).length > 0
|
|
245
266
|
},
|
|
246
267
|
decide: (event, change) =>
|
|
247
|
-
|
|
268
|
+
foldBefore(
|
|
248
269
|
event,
|
|
249
270
|
change,
|
|
250
271
|
deciders(event).map((watcher) => (latest: SessionChange) => (watcher.from === session ? askHere(watcher.mod, event, latest) : askAway(watcher, event, latest))),
|
|
@@ -556,7 +577,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
556
577
|
const action = String(request['action']) as Act
|
|
557
578
|
const task = typeof request['task'] === 'string' ? request['task'] : undefined
|
|
558
579
|
if (!isAct(action)) throw new Error(`cmod takes no action named ${action}.`)
|
|
559
|
-
if (target === session) return perform(caller, action, request['input'], task)
|
|
580
|
+
if (target === (await claude.session.id())) return perform(caller, action, request['input'], task)
|
|
560
581
|
return actElsewhere(caller, target, action, request['input'], task)
|
|
561
582
|
},
|
|
562
583
|
}
|
|
@@ -572,7 +593,8 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
572
593
|
const method = methods[e.method]
|
|
573
594
|
if (method === undefined) return undefined
|
|
574
595
|
const input = isObject(e.input) ? e.input : {}
|
|
575
|
-
|
|
596
|
+
const isDropping = (e.method === conversationMethods.subscribe || e.method === conversationMethods.watchBefore) && Array.isArray(input['subscriptions']) && input['subscriptions'].length === 0
|
|
597
|
+
if (e.method !== conversationMethods.act && !isDropping && input['session'] !== (await claude.session.id()) && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
|
|
576
598
|
try {
|
|
577
599
|
return { value: await method(input, e.caller) }
|
|
578
600
|
} catch (error) {
|
package/src/runtime/engine.ts
CHANGED
package/src/runtime/hooks.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClassicHookEvent, ClassicHookInputs
|
|
1
|
+
import type { ClassicHookEvent, ClassicHookInputs } from 'claude-code'
|
|
2
2
|
import { configFolders } from '../records.js'
|
|
3
3
|
import type { Claude } from './claude.js'
|
|
4
4
|
import type { RoutedHook } from './router.js'
|
|
@@ -10,26 +10,9 @@ import { messageOf } from '../utils/text.js'
|
|
|
10
10
|
|
|
11
11
|
export type Hook = Exclude<ClassicHookEvent, 'PreToolUse'>
|
|
12
12
|
|
|
13
|
-
export type
|
|
14
|
-
decision?: 'block'
|
|
15
|
-
reason?: string
|
|
16
|
-
hookSpecificOutput?: {
|
|
17
|
-
hookEventName?: Hook
|
|
18
|
-
additionalContext?: string
|
|
19
|
-
permissionDecision?: 'deny' | 'ask'
|
|
20
|
-
permissionDecisionReason?: string
|
|
21
|
-
sessionTitle?: string
|
|
22
|
-
initialUserMessage?: string
|
|
23
|
-
watchPaths?: string[]
|
|
24
|
-
reloadSkills?: boolean
|
|
25
|
-
decision?: PermissionRequestDecision
|
|
26
|
-
retry?: boolean
|
|
27
|
-
displayContent?: string
|
|
28
|
-
worktreePath?: string
|
|
29
|
-
}
|
|
30
|
-
}
|
|
13
|
+
export type HookFields = Readonly<Record<string, unknown>>
|
|
31
14
|
|
|
32
|
-
export type HookListener<H extends Hook> = (input: ClassicHookInputs[H]) =>
|
|
15
|
+
export type HookListener<H extends Hook> = (input: ClassicHookInputs[H]) => HookFields | undefined | Promise<HookFields | undefined>
|
|
33
16
|
|
|
34
17
|
export type RoutedEvent =
|
|
35
18
|
| `classic.${Hook}`
|
|
@@ -52,63 +35,18 @@ export type RoutedEvent =
|
|
|
52
35
|
| 'agent.spawn'
|
|
53
36
|
| 'turn.start'
|
|
54
37
|
|
|
55
|
-
export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
|
|
56
|
-
|
|
57
|
-
type ClassicFields = Record<string, unknown>
|
|
58
|
-
|
|
59
38
|
type CallFiles = { readonly read: string[]; readonly changed: string[] }
|
|
60
39
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const readFields: Record<Hook, readonly string[]> = {
|
|
64
|
-
Setup: ['additionalContext'],
|
|
65
|
-
SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
|
|
66
|
-
SessionEnd: [],
|
|
67
|
-
UserPromptSubmit: ['additionalContext', 'sessionTitle'],
|
|
68
|
-
UserPromptExpansion: ['additionalContext'],
|
|
69
|
-
InstructionsLoaded: [],
|
|
70
|
-
ConfigChange: [],
|
|
71
|
-
DirectoryAdded: [],
|
|
72
|
-
PreModelSwitch: ['permissionDecision', 'permissionDecisionReason'],
|
|
73
|
-
PostModelSwitch: ['additionalContext'],
|
|
74
|
-
PermissionRequest: ['decision'],
|
|
75
|
-
PermissionDenied: ['retry'],
|
|
76
|
-
PostToolUse: ['additionalContext'],
|
|
77
|
-
PostToolUseFailure: ['additionalContext'],
|
|
78
|
-
PostToolBatch: ['additionalContext'],
|
|
79
|
-
MessageDisplay: ['displayContent'],
|
|
80
|
-
SubagentStart: ['additionalContext'],
|
|
81
|
-
SubagentStop: ['additionalContext'],
|
|
82
|
-
TaskCreated: [],
|
|
83
|
-
TaskCompleted: [],
|
|
84
|
-
TeammateIdle: [],
|
|
85
|
-
Elicitation: [],
|
|
86
|
-
ElicitationResult: [],
|
|
87
|
-
Notification: [],
|
|
88
|
-
PreCompact: [],
|
|
89
|
-
PostCompact: [],
|
|
90
|
-
Stop: ['additionalContext'],
|
|
91
|
-
StopFailure: [],
|
|
92
|
-
CwdChanged: [],
|
|
93
|
-
FileChanged: [],
|
|
94
|
-
WorktreeCreate: ['worktreePath'],
|
|
95
|
-
WorktreeRemove: [],
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function classicHook<H extends Hook>(name: string, event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
|
|
40
|
+
export function classicHook<H extends Hook>(event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
|
|
99
41
|
const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
|
|
100
42
|
const ours = await Promise.resolve()
|
|
101
|
-
.then(
|
|
102
|
-
|
|
103
|
-
return answer === undefined ? undefined : classicResult(event, answer)
|
|
104
|
-
})
|
|
105
|
-
.catch((error: unknown): ClassicFields | undefined => {
|
|
106
|
-
if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: `${name}: the ${event} listener failed: ${messageOf(error)}` } }
|
|
43
|
+
.then(() => hook(e as ClassicHookInputs[H]))
|
|
44
|
+
.catch((error: unknown) => {
|
|
107
45
|
report({ id: `hook:${event}`, message: `The ${event} listener failed`, error })
|
|
108
46
|
return undefined
|
|
109
47
|
})
|
|
110
48
|
if (ours === undefined) return next(e)
|
|
111
|
-
return mergeClassic((await next(e)) as
|
|
49
|
+
return mergeClassic((await next(e)) as HookFields, ours)
|
|
112
50
|
}
|
|
113
51
|
return routed as unknown as RoutedHook<RoutedEvent>
|
|
114
52
|
}
|
|
@@ -134,9 +72,11 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
|
|
|
134
72
|
}
|
|
135
73
|
}
|
|
136
74
|
|
|
137
|
-
export
|
|
75
|
+
export type HomeFolders = { readonly home: string | undefined; readonly configRoot: string | undefined }
|
|
76
|
+
|
|
77
|
+
export async function callFiles(name: string, claude: Claude, folders: Promise<HomeFolders>, use: CallBase, cwd: string): Promise<CallFiles> {
|
|
138
78
|
try {
|
|
139
|
-
const
|
|
79
|
+
const { home, configRoot } = await folders
|
|
140
80
|
if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
|
|
141
81
|
const { shell, reads, writes } = callEffects(use, { cwd, home, ...(configRoot === undefined ? {} : { configRoot }), fs: claude.fs })
|
|
142
82
|
const known = (accesses: FileAccess[]) => accesses.map(({ path }) => path).filter((path) => shell === undefined || !dynamicPattern.test(path))
|
|
@@ -152,21 +92,8 @@ export async function callFiles(name: string, claude: Claude, use: CallBase, cwd
|
|
|
152
92
|
}
|
|
153
93
|
}
|
|
154
94
|
|
|
155
|
-
function
|
|
156
|
-
const
|
|
157
|
-
if (answer.decision === 'block') result['block'] = answer.reason ?? ''
|
|
158
|
-
const specific = answer.hookSpecificOutput ?? {}
|
|
159
|
-
for (const [field, value] of Object.entries(specific)) {
|
|
160
|
-
if (field === 'hookEventName' || (value === false && flags.includes(field))) continue
|
|
161
|
-
if (!readFields[event].includes(field)) throw new Error(`it answered ${field}, which ${event} does not read.`)
|
|
162
|
-
result[field] = field === 'additionalContext' ? [value] : value
|
|
163
|
-
}
|
|
164
|
-
return result
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
function mergeClassic(below: ClassicFields, ours: ClassicFields): ClassicFields {
|
|
169
|
-
const merged: ClassicFields = { ...below, ...ours }
|
|
95
|
+
function mergeClassic(below: HookFields, ours: HookFields): HookFields {
|
|
96
|
+
const merged: Record<string, unknown> = { ...below, ...ours }
|
|
170
97
|
const context = [...((below['additionalContext'] as string[] | undefined) ?? []), ...((ours['additionalContext'] as string[] | undefined) ?? [])]
|
|
171
98
|
if (context.length > 0) merged['additionalContext'] = context
|
|
172
99
|
return merged
|
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Allowed, FileChange, Disposable, Imports, Mod, ModDefinition, Pane
|
|
|
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'
|
|
7
|
-
import { firstLine, formatExit,
|
|
7
|
+
import { firstLine, formatExit, messageOf } from '../utils/text.js'
|
|
8
8
|
import type { Claude } from './claude.js'
|
|
9
9
|
import { beforeDeadline } from './deadline.js'
|
|
10
10
|
import { announcedOf, announcementMs, announcementOf } from './announce.js'
|
|
@@ -12,7 +12,8 @@ import { bind } from './bound.js'
|
|
|
12
12
|
import { serveConversations } from './conversations.js'
|
|
13
13
|
import { answerCall, answeringOwnCalls, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
|
|
14
14
|
import { keepEngine } from './engine.js'
|
|
15
|
-
import { classicHook,
|
|
15
|
+
import { classicHook, 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) }
|
|
@@ -939,7 +957,7 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
939
957
|
return served ?? answerCall(definition.name, api, e)
|
|
940
958
|
})
|
|
941
959
|
const agents = toolCalls(claude, router)
|
|
942
|
-
keepEngine(mod, { claude: checked, router, toolCalls: agents, report })
|
|
960
|
+
keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined })
|
|
943
961
|
const names = new Set<string>()
|
|
944
962
|
const reserveName = (type: string, name: string, taken: string): Disposable => {
|
|
945
963
|
const key = `${type}:${name}`
|
|
@@ -954,10 +972,6 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
954
972
|
await failsAs('the details of its session did not load', () => followCurrent(undefined))
|
|
955
973
|
await failsAs('its setup function threw', () => definition.setup(mod))
|
|
956
974
|
isSetUp = true
|
|
957
|
-
const permissionHooks = permissionEvents.filter((event) => router.has(event))
|
|
958
|
-
if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
|
|
959
|
-
throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
|
|
960
|
-
}
|
|
961
975
|
await failsAs('its open panes did not load', () => area.restorePanes())
|
|
962
976
|
return { mod, added, imports, raise: client.raise }
|
|
963
977
|
}
|
|
@@ -965,7 +979,7 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
965
979
|
function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
|
|
966
980
|
const bound = Object.entries(keys)
|
|
967
981
|
if (bound.length === 0) return claude
|
|
968
|
-
const
|
|
982
|
+
const addCommand: Claude['command']['register'] = (command) => {
|
|
969
983
|
for (const [key, commandName] of bound) {
|
|
970
984
|
if (commandName === command.name && command.immediate !== true) {
|
|
971
985
|
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 +987,7 @@ function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string
|
|
|
973
987
|
}
|
|
974
988
|
return claude.command.register(command)
|
|
975
989
|
}
|
|
976
|
-
return { ...claude, command: { ...claude.command, register } }
|
|
990
|
+
return { ...claude, command: { ...claude.command, register: addCommand } }
|
|
977
991
|
}
|
|
978
992
|
|
|
979
993
|
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/router.ts
CHANGED
|
@@ -8,7 +8,6 @@ export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: Route
|
|
|
8
8
|
|
|
9
9
|
export type Router = {
|
|
10
10
|
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): Disposable
|
|
11
|
-
has(event: RoutedEvent): boolean
|
|
12
11
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
13
12
|
}
|
|
14
13
|
|
|
@@ -29,7 +28,6 @@ export function createRouter(): Router {
|
|
|
29
28
|
},
|
|
30
29
|
}
|
|
31
30
|
},
|
|
32
|
-
has: (event) => hooks.has(event),
|
|
33
31
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
34
32
|
const chain = hooks.get(event)
|
|
35
33
|
const call = async (index: number, current: unknown): Promise<unknown> => {
|