@cmodjs/core 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +1 -1
- package/docs/mod.md +2 -0
- package/docs/session.md +9 -1
- package/docs/testing.md +4 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +2 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +1 -1
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +2 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +4 -3
- package/package.json +1 -1
- package/records.d.ts +1 -1
- package/records.js +1 -1
- package/register.d.ts.map +1 -1
- package/register.js +58 -51
- package/runtime/before.d.ts +1 -1
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +6 -5
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +46 -19
- package/runtime/hooks.d.ts +2 -1
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +7 -0
- package/runtime/lifecycle.d.ts +2 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +31 -9
- package/runtime/permission-check.d.ts +3 -0
- package/runtime/permission-check.d.ts.map +1 -1
- package/runtime/permission-check.js +19 -6
- package/runtime/router.d.ts +3 -1
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +19 -11
- package/runtime/sessions.d.ts +13 -4
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +67 -26
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +3 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +30 -2
- package/session/context/turn/message/note.d.ts +7 -0
- package/session/context/turn/message/note.d.ts.map +1 -1
- package/session/context/turn/message/note.js +12 -0
- package/session/session.d.ts +3 -1
- package/session/session.d.ts.map +1 -1
- package/src/jobs/context.ts +2 -1
- package/src/jobs/permissions.ts +8 -4
- package/src/jobs/prompt.ts +2 -1
- package/src/jobs/slash-command.ts +4 -3
- package/src/records.ts +1 -1
- package/src/register.ts +63 -52
- package/src/runtime/before.ts +7 -6
- package/src/runtime/conversations.ts +54 -27
- package/src/runtime/hooks.ts +9 -1
- package/src/runtime/lifecycle.ts +37 -13
- package/src/runtime/permission-check.ts +44 -22
- package/src/runtime/router.ts +23 -13
- package/src/runtime/sessions.ts +91 -37
- package/src/runtime/toaster.ts +2 -0
- package/src/session/context/turn/message/message.ts +27 -2
- package/src/session/context/turn/message/note.ts +16 -0
- package/src/session/session.ts +1 -2
- package/src/testing/fake-claude.ts +4 -3
- package/src/testing.ts +6 -5
- package/testing/fake-claude.d.ts +1 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +3 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +5 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Args, EventResult, Frozen } from 'claude-code'
|
|
2
2
|
import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
|
|
3
3
|
import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
|
|
4
|
-
import { Note } from '../session/context/turn/message/note.js'
|
|
4
|
+
import { modNote, Note } from '../session/context/turn/message/note.js'
|
|
5
5
|
import { Person } from '../session/context/turn/message/sender.js'
|
|
6
6
|
import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
|
|
7
7
|
import { Session, UsageChange, type BeforeEvent, type Surface, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
|
|
@@ -9,18 +9,19 @@ 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 { foldBefore, installBefore, relayOf, type Place } from './before.js'
|
|
12
|
+
import { beforeAnswerKeys, 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'
|
|
16
|
-
import type { Router } from './router.js'
|
|
17
|
-
import {
|
|
16
|
+
import type { RoutedHook, Router } from './router.js'
|
|
17
|
+
import { PassedTimeout } from './deadline.js'
|
|
18
|
+
import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
|
|
18
19
|
|
|
19
20
|
type Request = Readonly<Record<string, unknown>>
|
|
20
21
|
|
|
21
22
|
type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
|
|
22
23
|
|
|
23
|
-
type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[] }
|
|
24
|
+
type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[]; readonly refusing?: readonly Subscription[] }
|
|
24
25
|
|
|
25
26
|
type Watcher = Watch & { readonly from: string }
|
|
26
27
|
|
|
@@ -37,6 +38,8 @@ const bufferedTaskRows = 200
|
|
|
37
38
|
|
|
38
39
|
const beforeDeadlineMs = 5000
|
|
39
40
|
|
|
41
|
+
const awayFoldMs = 8000
|
|
42
|
+
|
|
40
43
|
const livenessMs = 5000
|
|
41
44
|
|
|
42
45
|
|
|
@@ -185,7 +188,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
185
188
|
reject(error)
|
|
186
189
|
},
|
|
187
190
|
}
|
|
188
|
-
const timer = deadlineMs === undefined ? watchLife(settle, to) : claude.clock.after(deadlineMs, () => settle.reject(new
|
|
191
|
+
const timer = deadlineMs === undefined ? watchLife(settle, to) : claude.clock.after(deadlineMs, () => settle.reject(new PassedTimeout(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
|
|
189
192
|
asked.set(id, settle)
|
|
190
193
|
post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
|
|
191
194
|
})
|
|
@@ -240,11 +243,24 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
240
243
|
return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
|
|
241
244
|
}
|
|
242
245
|
|
|
243
|
-
const
|
|
246
|
+
const isUnderHere = (task: string, ancestor: string) => {
|
|
247
|
+
for (let at: string | undefined = task, steps = 0; at !== undefined && steps < 64; at = conversation.state.tasks[at]?.parent, steps += 1) if (at === ancestor) return true
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const refuses = (watcher: Watcher, event: BeforeEvent, change: SessionChange) =>
|
|
252
|
+
beforeAnswerKeys[event].includes('refuse') && (watcher.refusing ?? []).some(({ event: heard, matcher, scope }) => isInScope(scope ?? {}, session, change, isUnderHere) && isHeardBy(heard, matcher === undefined ? undefined : receivedMatcher(matcher), change))
|
|
253
|
+
|
|
254
|
+
const askAway = async (watcher: Watcher, event: BeforeEvent, change: SessionChange, endsAt: number) => {
|
|
244
255
|
try {
|
|
245
|
-
|
|
256
|
+
const leftMs = Math.min(beforeDeadlineMs, endsAt - (await claude.clock.now()))
|
|
257
|
+
if (leftMs <= 0) throw new PassedTimeout(`the ${awayFoldMs / 1000} s cmod waits on other sessions ran out before session ${watcher.from} was asked`)
|
|
258
|
+
return await ask(watcher.from, { kind: 'before', mod: watcher.mod, event, change, project: project() }, leftMs)
|
|
246
259
|
} catch (error) {
|
|
247
|
-
if (await isRunning(watcher.from))
|
|
260
|
+
if (await isRunning(watcher.from)) {
|
|
261
|
+
if (error instanceof PassedTimeout && refuses(watcher, event, change)) return { refuse: `${watcher.mod}: ${error.message}` }
|
|
262
|
+
throw error
|
|
263
|
+
}
|
|
248
264
|
watchers = watchers.filter((each) => each.from !== watcher.from)
|
|
249
265
|
claude.ui.log(`cmod: session ${watcher.from} ended without clearing its before listeners, so they are cleared now`, { to: 'debug' })
|
|
250
266
|
await claude.fs.write(`${watches}/${watcher.from}.json`, '[]')
|
|
@@ -264,13 +280,20 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
264
280
|
await Promise.all([starting, watching])
|
|
265
281
|
return deciders(event).length > 0
|
|
266
282
|
},
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
283
|
+
fold: () => {
|
|
284
|
+
let endsAt: Promise<number> | undefined
|
|
285
|
+
return (event, change) =>
|
|
286
|
+
foldBefore(
|
|
287
|
+
event,
|
|
288
|
+
change,
|
|
289
|
+
deciders(event).map((watcher) => async (latest: SessionChange) => {
|
|
290
|
+
if (watcher.from === session) return askHere(watcher.mod, event, latest)
|
|
291
|
+
endsAt ??= claude.clock.now().then((now) => now + awayFoldMs)
|
|
292
|
+
return askAway(watcher, event, latest, await endsAt)
|
|
293
|
+
}),
|
|
294
|
+
(error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
|
|
295
|
+
)
|
|
296
|
+
},
|
|
274
297
|
},
|
|
275
298
|
})
|
|
276
299
|
|
|
@@ -321,22 +344,24 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
321
344
|
return next(e)
|
|
322
345
|
})
|
|
323
346
|
|
|
324
|
-
|
|
347
|
+
const recordAppend: RoutedHook<'session.append'> = async (e, next) => {
|
|
325
348
|
const kept = await next(e)
|
|
326
349
|
if (kept.deny !== undefined || session === '') return kept
|
|
327
350
|
const cwd = e.message.type === 'assistant' ? await claude.session.cwd() : ''
|
|
328
351
|
void take(rowOfAppend({ uuid: kept.uuid, door: e.door, origin: e.origin, message: kept.message }, new Date().toISOString(), cwd), e.agentId).catch((error: unknown) => report('append', 'Missed a message', error))
|
|
329
352
|
return kept
|
|
330
|
-
}
|
|
353
|
+
}
|
|
354
|
+
router.add('session.append', recordAppend, (e, _error, next) => recordAppend(e, next))
|
|
331
355
|
|
|
332
|
-
|
|
356
|
+
const recordCompaction: RoutedHook<'session.compact'> = async (e, next) => {
|
|
333
357
|
const compacted = await next(e)
|
|
334
358
|
if (e.trigger === 'precompute' || compacted.skip !== undefined || session === '') return compacted
|
|
335
359
|
const at = new Date().toISOString()
|
|
336
360
|
const boundary: Row = { id: `compact:${e.agentId ?? session}:${at}`, at, type: 'system', name: 'compact_boundary', isMeta: false, isSummary: false, content: [] }
|
|
337
361
|
void take(boundary, e.agentId).catch((error: unknown) => report('compact', 'Missed a compaction', error))
|
|
338
362
|
return compacted
|
|
339
|
-
}
|
|
363
|
+
}
|
|
364
|
+
router.add('session.compact', recordCompaction, (e, _error, next) => recordCompaction(e, next))
|
|
340
365
|
|
|
341
366
|
router.add('session.measure', async (e, next) => {
|
|
342
367
|
const measured = await next(e)
|
|
@@ -344,7 +369,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
344
369
|
return measured
|
|
345
370
|
})
|
|
346
371
|
|
|
347
|
-
|
|
372
|
+
const recordSpawn: RoutedHook<'agent.spawn'> = async (e, next) => {
|
|
348
373
|
const spawned = await next(e)
|
|
349
374
|
if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
|
|
350
375
|
const id = spawned.agentId
|
|
@@ -355,7 +380,8 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
355
380
|
waitingRows.delete(id)
|
|
356
381
|
for (const row of waiting) dispatch(session, conversation.add(row, id))
|
|
357
382
|
return spawned
|
|
358
|
-
}
|
|
383
|
+
}
|
|
384
|
+
router.add('agent.spawn', recordSpawn, (e, _error, next) => recordSpawn(e, next))
|
|
359
385
|
|
|
360
386
|
router.add('session.receive', async (e, next) => {
|
|
361
387
|
const sent = relayOf(e)
|
|
@@ -398,13 +424,13 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
398
424
|
await checkAct(caller, action, input)
|
|
399
425
|
await starting
|
|
400
426
|
if (action === 'abort') return abortTurn(input, task)
|
|
401
|
-
if (task !== undefined) return performInTask(action, input, task)
|
|
427
|
+
if (task !== undefined) return performInTask(caller, action, input, task)
|
|
402
428
|
if (action === 'usage') return usageOf(claude)
|
|
403
429
|
if (action === 'surfaces') return [...(await claude.session.surfaces())] satisfies Surface[]
|
|
404
430
|
if (action === 'send') {
|
|
405
431
|
const { type, text, as } = input as SessionSend
|
|
406
432
|
if (as === Session) throw new Error(`session ${session} cannot send as itself to itself.`)
|
|
407
|
-
const delivered = type === Note ? await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }) : await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
|
|
433
|
+
const delivered = type === Note ? await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text: modNote(caller, text) }] } }) : await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
|
|
408
434
|
if ('deny' in delivered && typeof delivered.deny === 'string') throw new Error(`Claude Code refused the note: ${delivered.deny}`)
|
|
409
435
|
if ('drop' in delivered && typeof delivered.drop === 'string') throw new Error(`Claude Code dropped the prompt: ${delivered.drop}`)
|
|
410
436
|
return null
|
|
@@ -424,13 +450,14 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
424
450
|
return null
|
|
425
451
|
}
|
|
426
452
|
|
|
427
|
-
const performInTask = async (action: Act, input: unknown, task: string): Promise<unknown> => {
|
|
453
|
+
const performInTask = async (caller: string, action: Act, input: unknown, task: string): Promise<unknown> => {
|
|
428
454
|
const running = conversation.state.tasks[task]
|
|
429
455
|
if (action === 'usage') return json(['history', 'usage', session, '--task', task])
|
|
430
456
|
if (action === 'surfaces') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
|
|
431
457
|
if (action !== 'send') throw new Error(`cmod: ${action} runs only on a session's own main loop.`)
|
|
432
458
|
if (running === undefined || running.closedAt !== undefined) throw ended(task)
|
|
433
|
-
const
|
|
459
|
+
const { type, text } = input as SessionSend
|
|
460
|
+
const sent = await claude.session.send({ to: { agentId: task }, text: type === Note ? modNote(caller, text) : text })
|
|
434
461
|
if (sent.isDelivered !== true) throw ended(task)
|
|
435
462
|
return null
|
|
436
463
|
}
|
|
@@ -567,7 +594,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
567
594
|
if (caller === undefined) throw new Error('cmod hears no caller for this before listener.')
|
|
568
595
|
const target = String(request['session'])
|
|
569
596
|
const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
|
|
570
|
-
ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))] })
|
|
597
|
+
ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))], refusing: list.filter((each) => each.onFailure === 'refuse') })
|
|
571
598
|
await publishWatches()
|
|
572
599
|
return null
|
|
573
600
|
},
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ClassicHookEvent, ClassicHookInputs } from 'claude-code'
|
|
2
2
|
import { configFolders } from '../records.js'
|
|
3
3
|
import type { Claude } from './claude.js'
|
|
4
|
-
import type { RoutedHook } from './router.js'
|
|
4
|
+
import type { RoutedFailure, RoutedHook } from './router.js'
|
|
5
5
|
import type { Failure } from './toaster.js'
|
|
6
6
|
import { callEffects, type CallBase, type FileAccess } from '../utils/call-effects.js'
|
|
7
7
|
import { dynamicPattern } from '../utils/parse-shell.js'
|
|
@@ -51,6 +51,14 @@ export function classicHook<H extends Hook>(event: H, hook: HookListener<H>, rep
|
|
|
51
51
|
return routed as unknown as RoutedHook<RoutedEvent>
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export function classicFailure<H extends Hook>(failed: (input: ClassicHookInputs[H], error: Error) => HookFields | undefined | Promise<HookFields | undefined>): RoutedFailure<RoutedEvent> {
|
|
55
|
+
const routed = async (e: unknown, error: Error, next: (e: unknown) => Promise<unknown>) => {
|
|
56
|
+
const ours = await failed(e as ClassicHookInputs[H], error)
|
|
57
|
+
return ours === undefined ? undefined : mergeClassic((await next(e)) as HookFields, ours)
|
|
58
|
+
}
|
|
59
|
+
return routed as unknown as RoutedFailure<RoutedEvent>
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
const frontmatter = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/
|
|
55
63
|
|
|
56
64
|
const baseDirectoryLine = /^Base directory for this skill: [^\n]*\n\n/
|
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement, Timer } from 'claude-code'
|
|
1
|
+
import type { Args, ClassicHookInputs, EventResult, Frozen, HookBudget, HookFailure, PluginOptions, RenderElement, Timer } from 'claude-code'
|
|
2
2
|
import type { Step } from '../installer.js'
|
|
3
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'
|
|
@@ -12,7 +12,7 @@ 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, userSkillHook, type Hook, type HookListener, type RoutedEvent } from './hooks.js'
|
|
15
|
+
import { classicFailure, classicHook, userSkillHook, type Hook, type HookFields, type HookListener, type RoutedEvent } from './hooks.js'
|
|
16
16
|
import type { PermissionRoutes } from './permission-check.js'
|
|
17
17
|
import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
|
|
18
18
|
import { cmodError, lastLineOf, locatingPrograms, readLines, type Programs } from './programs.js'
|
|
@@ -21,7 +21,7 @@ import { fitsOption } from '../options.js'
|
|
|
21
21
|
import { createInstaller } from './installer.js'
|
|
22
22
|
import { createOptions, MissingOptions } from './options.js'
|
|
23
23
|
import { createRouter, type Router, type RouterNext } from './router.js'
|
|
24
|
-
import { conversationMethods, createSessionsClient } from './sessions.js'
|
|
24
|
+
import { conversationMethods, createSessionsClient, type SessionsClient } from './sessions.js'
|
|
25
25
|
import type { SessionChange } from '../session/conversation.js'
|
|
26
26
|
import type { BeforeEvent } from '../session/session.js'
|
|
27
27
|
import { matches } from '../utils/matcher.js'
|
|
@@ -52,6 +52,7 @@ export type Lifecycle<State extends object> = {
|
|
|
52
52
|
readonly toasts: readonly ShownToast[]
|
|
53
53
|
start(claude: Claude, read: (claude: Claude) => Promise<Plugin>): Promise<void>
|
|
54
54
|
route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
55
|
+
caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, failure: HookFailure): Promise<EventResult<N>>
|
|
55
56
|
raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -72,7 +73,7 @@ type ActiveMod<State extends object, Declared extends Options> = {
|
|
|
72
73
|
readonly mod: Mod<State, Declared>
|
|
73
74
|
readonly added: readonly string[]
|
|
74
75
|
readonly imports: Imports
|
|
75
|
-
readonly raise:
|
|
76
|
+
readonly raise: SessionsClient['raise']
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
|
|
@@ -88,7 +89,9 @@ const cmodWaitMs = 60_000
|
|
|
88
89
|
|
|
89
90
|
const cmodSlowCheckMs = 10_000
|
|
90
91
|
|
|
91
|
-
const
|
|
92
|
+
const hookMs = 10_000 satisfies HookBudget['ms']
|
|
93
|
+
|
|
94
|
+
const startHoldMs = hookMs - 1_000
|
|
92
95
|
|
|
93
96
|
const dependencyCallMs = 30_000
|
|
94
97
|
|
|
@@ -169,7 +172,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
169
172
|
let activation: Promise<void> | undefined
|
|
170
173
|
let mod: Mod<State> | undefined
|
|
171
174
|
let imports: Imports | undefined
|
|
172
|
-
let raise:
|
|
175
|
+
let raise: SessionsClient['raise'] | undefined
|
|
173
176
|
let failure: unknown
|
|
174
177
|
let pluginOptions = options
|
|
175
178
|
const installer = createInstaller(definition.name, () => claude())
|
|
@@ -628,6 +631,16 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
628
631
|
if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined) return answer
|
|
629
632
|
return runtime.toaster.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.props, render.requestId) as EventResult<N>
|
|
630
633
|
},
|
|
634
|
+
async caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, { kind, message }: HookFailure): Promise<EventResult<N>> {
|
|
635
|
+
const call = e as Frozen<Args<'cmod.call'>>
|
|
636
|
+
const isAsk = event === 'cmod.call' && call.to === definition.name && beforeAskOf(call) !== undefined
|
|
637
|
+
const reasons: Readonly<Record<string, string>> = { timeout: `it ran past the ${hookMs / 1000} s Claude Code gives a hook`, throw: message ?? 'it threw' }
|
|
638
|
+
const reason = reasons[kind]
|
|
639
|
+
if (reason === undefined) return isAsk ? ({ value: null } as EventResult<N>) : next(e)
|
|
640
|
+
const error = new Error(reason)
|
|
641
|
+
toastFailure({ id: `stopped:${event}`, message: `Claude Code stopped its ${event.replace(/^classic\./, '')} hook`, error })
|
|
642
|
+
return router.dispatchFailure(event, e, error, next)
|
|
643
|
+
},
|
|
631
644
|
}
|
|
632
645
|
}
|
|
633
646
|
|
|
@@ -704,7 +717,8 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
704
717
|
}))
|
|
705
718
|
const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
|
|
706
719
|
const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
|
|
707
|
-
const hookOn = <H extends Hook>(hook: H, listener: HookListener<H
|
|
720
|
+
const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>, failed?: (input: ClassicHookInputs[H], error: Error) => HookFields | undefined): Disposable =>
|
|
721
|
+
router.add(`classic.${hook}`, classicHook(hook, listener, report), failed === undefined ? undefined : classicFailure(failed))
|
|
708
722
|
const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
|
|
709
723
|
const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
|
|
710
724
|
read: () => checked.settings.read({ source }),
|
|
@@ -718,8 +732,8 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
718
732
|
claude: checked,
|
|
719
733
|
report: (id, message, error) => report({ id, message, error }),
|
|
720
734
|
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),
|
|
735
|
+
hookOn: (hook, listener, failed) => hookOn(hook as Hook, listener as HookListener<Hook>, failed),
|
|
736
|
+
routeOn: (event: RoutedEvent, hook: unknown, failed?: unknown) => router.add(event, hook as never, failed as never),
|
|
723
737
|
permissionRoutes: runtime.permissionRoutes,
|
|
724
738
|
})
|
|
725
739
|
let followed: Parameters<typeof client.follow>[0] | undefined
|
|
@@ -944,10 +958,8 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
944
958
|
client.changed(changedSession, change, isInProject !== false)
|
|
945
959
|
return { value: null }
|
|
946
960
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
return { value: (await client.answerBefore(asking, event, change, isInProject !== false)) ?? null }
|
|
950
|
-
}
|
|
961
|
+
const ask = beforeAskOf(call)
|
|
962
|
+
if (ask !== undefined) return { value: (await client.answerBefore(ask.session, ask.event, ask.change, ask.isInProject)) ?? null }
|
|
951
963
|
if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
|
|
952
964
|
if (e.method === openPageMethod) {
|
|
953
965
|
await settingsPages.open(String(e.input))
|
|
@@ -955,6 +967,10 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
955
967
|
}
|
|
956
968
|
const served = await conversations?.answer(call)
|
|
957
969
|
return served ?? answerCall(definition.name, api, e)
|
|
970
|
+
}, (e, error) => {
|
|
971
|
+
if (e.to !== definition.name) return undefined
|
|
972
|
+
const ask = beforeAskOf(e)
|
|
973
|
+
return ask === undefined ? { deny: `${definition.name}: ${error.message}` } : { value: client.failedBefore(ask.session, ask.event, ask.change, ask.isInProject, error) ?? null }
|
|
958
974
|
})
|
|
959
975
|
const agents = toolCalls(claude, router)
|
|
960
976
|
keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined })
|
|
@@ -997,6 +1013,14 @@ function checkedMs(name: string, subject: string, ms: number): number {
|
|
|
997
1013
|
return ms
|
|
998
1014
|
}
|
|
999
1015
|
|
|
1016
|
+
type BeforeAsk = { readonly session: string; readonly event: BeforeEvent; readonly change: SessionChange; readonly isInProject: boolean }
|
|
1017
|
+
|
|
1018
|
+
function beforeAskOf(call: Frozen<Args<'cmod.call'>> & { readonly caller?: string }): BeforeAsk | undefined {
|
|
1019
|
+
if (call.method !== conversationMethods.before || call.caller !== cmodPluginName) return undefined
|
|
1020
|
+
const { session, event, change, isInProject } = call.input as Omit<BeforeAsk, 'isInProject'> & { readonly isInProject?: boolean }
|
|
1021
|
+
return { session, event, change, isInProject: isInProject !== false }
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1000
1024
|
async function failsAs(subject: string, step: () => unknown): Promise<void> {
|
|
1001
1025
|
try {
|
|
1002
1026
|
await step()
|
|
@@ -4,6 +4,7 @@ import { defined, processPayloadOf, recordOf, type HookOn, type RouteOn, type To
|
|
|
4
4
|
|
|
5
5
|
export type PermissionUse = {
|
|
6
6
|
readonly raise: (method: string, payload: HookFields) => Promise<HookFields | undefined>
|
|
7
|
+
readonly refusalFor: (method: string, payload: HookFields, error: Error) => { readonly refuse: string } | undefined
|
|
7
8
|
readonly hookOn: HookOn
|
|
8
9
|
readonly routeOn: RouteOn
|
|
9
10
|
readonly current: () => { readonly id: string; readonly cwd: string }
|
|
@@ -15,33 +16,54 @@ type Verdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?:
|
|
|
15
16
|
|
|
16
17
|
const verdictName = (decision: Verdict['decision']) => (decision === 'deny' ? 'refuse' : decision)
|
|
17
18
|
|
|
18
|
-
export const permissionRoutes: PermissionRoutes = (method, { raise, hookOn, routeOn, current }) => {
|
|
19
|
+
export const permissionRoutes: PermissionRoutes = (method, { raise, refusalFor, hookOn, routeOn, current }) => {
|
|
19
20
|
const isBefore = method.startsWith('before')
|
|
20
21
|
if (method.endsWith('PermissionCheck')) {
|
|
21
|
-
|
|
22
|
-
const below = (await next(e)) as Verdict
|
|
22
|
+
const payloadOf = (e: ToolCheck, below: Verdict) => {
|
|
23
23
|
const { id, cwd } = current()
|
|
24
|
-
|
|
24
|
+
return { session: id, cwd, ...defined({ task: e.agentId, callId: e.tool_use_id, reason: below.reason, rule: below.rule, ceiling: e.ceiling === undefined ? undefined : verdictName(e.ceiling) }), name: e.tool, input: recordOf(e.input), decision: verdictName(below.decision) }
|
|
25
|
+
}
|
|
26
|
+
const failed = async (e: ToolCheck, error: Error, next: (e: ToolCheck) => Promise<unknown>) => {
|
|
27
|
+
const refusal = refusalFor(method, payloadOf(e, (await next(e)) as Verdict), error)
|
|
28
|
+
return refusal === undefined ? undefined : { decision: 'deny' as const, reason: refusal.refuse }
|
|
29
|
+
}
|
|
30
|
+
return routeOn(
|
|
31
|
+
'tool.check',
|
|
32
|
+
async (e: ToolCheck, next) => {
|
|
33
|
+
const below = (await next(e)) as Verdict
|
|
34
|
+
const payload = payloadOf(e, below)
|
|
35
|
+
if (!isBefore) {
|
|
36
|
+
void raise(method, payload)
|
|
37
|
+
return below
|
|
38
|
+
}
|
|
39
|
+
const answer = await raise(method, payload)
|
|
40
|
+
if (answer === undefined || below.decision === 'deny') return below
|
|
41
|
+
if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
|
|
42
|
+
if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
|
|
43
|
+
return answer['allow'] === true ? { decision: 'allow' } : below
|
|
44
|
+
},
|
|
45
|
+
isBefore ? failed : undefined,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const refused = (refuse: string, interrupt?: unknown) => ({ decision: defined({ behavior: 'deny', message: refuse, interrupt }) })
|
|
49
|
+
return hookOn(
|
|
50
|
+
'PermissionRequest',
|
|
51
|
+
async (input) => {
|
|
52
|
+
const payload = processPayloadOf('PermissionRequest', input)
|
|
25
53
|
if (!isBefore) {
|
|
26
54
|
void raise(method, payload)
|
|
27
|
-
return
|
|
55
|
+
return undefined
|
|
28
56
|
}
|
|
29
57
|
const answer = await raise(method, payload)
|
|
30
|
-
if (answer === undefined
|
|
31
|
-
if (typeof answer['refuse'] === 'string') return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
const answer = await raise(method, payload)
|
|
43
|
-
if (answer === undefined) return undefined
|
|
44
|
-
if (typeof answer['refuse'] === 'string') return { decision: defined({ behavior: 'deny', message: answer['refuse'], interrupt: answer['interrupt'] }) }
|
|
45
|
-
return answer['allow'] === true ? { decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) } : undefined
|
|
46
|
-
})
|
|
58
|
+
if (answer === undefined) return undefined
|
|
59
|
+
if (typeof answer['refuse'] === 'string') return refused(answer['refuse'], answer['interrupt'])
|
|
60
|
+
return answer['allow'] === true ? { decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) } : undefined
|
|
61
|
+
},
|
|
62
|
+
isBefore
|
|
63
|
+
? (input, error) => {
|
|
64
|
+
const refusal = refusalFor(method, processPayloadOf('PermissionRequest', input), error)
|
|
65
|
+
return refusal === undefined ? undefined : refused(refusal.refuse)
|
|
66
|
+
}
|
|
67
|
+
: undefined,
|
|
68
|
+
)
|
|
47
69
|
}
|
package/src/runtime/router.ts
CHANGED
|
@@ -6,36 +6,46 @@ export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<
|
|
|
6
6
|
|
|
7
7
|
export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: RouterNext<N>) => EventResult<N> | Promise<EventResult<N>>
|
|
8
8
|
|
|
9
|
+
export type RoutedFailure<N extends RoutedEvent> = (e: Frozen<Args<N>>, error: Error, next: RouterNext<N>) => EventResult<N> | undefined | Promise<EventResult<N> | undefined>
|
|
10
|
+
|
|
9
11
|
export type Router = {
|
|
10
|
-
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): Disposable
|
|
12
|
+
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>, failed?: RoutedFailure<N>): Disposable
|
|
11
13
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
14
|
+
dispatchFailure<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, error: Error, next: RouterNext<N>): Promise<EventResult<N>>
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
type
|
|
17
|
+
type Route = { readonly hook: (e: unknown, next: (e: unknown) => Promise<unknown>) => unknown; readonly failed?: (e: unknown, error: Error, next: (e: unknown) => Promise<unknown>) => unknown }
|
|
15
18
|
|
|
16
19
|
export function createRouter(): Router {
|
|
17
|
-
const
|
|
20
|
+
const routes = new Map<RoutedEvent, Route[]>()
|
|
18
21
|
|
|
19
22
|
return {
|
|
20
|
-
add(event, hook) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
+
add(event, hook, failed) {
|
|
24
|
+
const route = { hook, ...(failed === undefined ? {} : { failed }) } as Route
|
|
25
|
+
routes.set(event, [...(routes.get(event) ?? []), route])
|
|
23
26
|
return {
|
|
24
27
|
dispose() {
|
|
25
|
-
const chain = (
|
|
26
|
-
if (chain.length === 0)
|
|
27
|
-
else
|
|
28
|
+
const chain = (routes.get(event) ?? []).filter((each) => each !== route)
|
|
29
|
+
if (chain.length === 0) routes.delete(event)
|
|
30
|
+
else routes.set(event, chain)
|
|
28
31
|
},
|
|
29
32
|
}
|
|
30
33
|
},
|
|
31
34
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
32
|
-
const chain =
|
|
35
|
+
const chain = routes.get(event)
|
|
33
36
|
const call = async (index: number, current: unknown): Promise<unknown> => {
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
return hook(current, (passed) => call(index + 1, passed))
|
|
37
|
+
const route = chain?.[index]
|
|
38
|
+
if (route === undefined) return next(current as Frozen<Args<N>>)
|
|
39
|
+
return route.hook(current, (passed) => call(index + 1, passed))
|
|
37
40
|
}
|
|
38
41
|
return call(0, e) as Promise<EventResult<N>>
|
|
39
42
|
},
|
|
43
|
+
async dispatchFailure<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, error: Error, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
44
|
+
for (const route of routes.get(event) ?? []) {
|
|
45
|
+
const answer = await route.failed?.(e, error, next as (e: unknown) => Promise<unknown>)
|
|
46
|
+
if (answer !== undefined) return answer as EventResult<N>
|
|
47
|
+
}
|
|
48
|
+
return next(e)
|
|
49
|
+
},
|
|
40
50
|
}
|
|
41
51
|
}
|