@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
package/src/runtime/sessions.ts
CHANGED
|
@@ -12,12 +12,13 @@ import { GoalChange, type Goal } from '../session/goal/goal.js'
|
|
|
12
12
|
import { TaskEnd, TaskStart, type Task } from '../session/goal/task.js'
|
|
13
13
|
import { beforeEvents, processEvents, Session, sessionEvents, type BeforeEvent, type Placement, type BeforeMethods, type Surface, type OnMethods, type ProcessBeforeMethods, type ProcessName, type ProcessOnMethods, type RecordPart, type SearchOptions, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskOwner, type TaskStartArgs, type ToolCallArgs, type Usage } from '../session/session.js'
|
|
14
14
|
import { matches, sentMatcher, type Matcher, type SentMatcher } from '../utils/matcher.js'
|
|
15
|
-
import { foldAnswers, type Answers, type Place } from './before.js'
|
|
15
|
+
import { beforeAnswerKeys, foldAnswers, foldBefore, methodOf, takenAnswer, type Answers, type Place } from './before.js'
|
|
16
16
|
import type { Claude } from './claude.js'
|
|
17
17
|
import { cmodPluginName } from './dependencies.js'
|
|
18
18
|
import { messageOf } from '../utils/text.js'
|
|
19
19
|
import { listenerOf, PassedTimeout, withinTime, type ListenOptions, type ListenTime } from './deadline.js'
|
|
20
|
-
import { callFiles, type
|
|
20
|
+
import { callFiles, type HomeFolders, type HookFields } from './hooks.js'
|
|
21
|
+
import type { PermissionRoutes } from './permission-check.js'
|
|
21
22
|
|
|
22
23
|
export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
|
|
23
24
|
|
|
@@ -50,8 +51,6 @@ const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
|
|
|
50
51
|
|
|
51
52
|
export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart ? 'tasks' : 'prompt')
|
|
52
53
|
|
|
53
|
-
const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
|
|
54
|
-
|
|
55
54
|
export const isAct = (action: string): action is Act => action === 'callTool' || action in actPermissions
|
|
56
55
|
|
|
57
56
|
export function toolPermission(tool: string): string | undefined {
|
|
@@ -134,7 +133,13 @@ export function isInScope(scope: Scope, session: string, change: SessionChange,
|
|
|
134
133
|
return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
export function
|
|
136
|
+
export function withoutPermissionCheck(name: string, hearer: string): string {
|
|
137
|
+
return `${name}: ${hearer} hears Claude Code's permission check, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>) => Disposable
|
|
141
|
+
|
|
142
|
+
export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn, permissionRoutes }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean; readonly hookOn?: HookOn; readonly routeOn?: RouteOn; readonly permissionRoutes?: () => PermissionRoutes | undefined }): SessionsClient {
|
|
138
143
|
const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
|
|
139
144
|
const listening = new Map<string, Set<Listening>>()
|
|
140
145
|
const deciding = new Map<string, Set<Listening>>()
|
|
@@ -197,6 +202,13 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
197
202
|
|
|
198
203
|
|
|
199
204
|
|
|
205
|
+
const invoke = (method: string, options: ListenOptions, run: (time: ListenTime) => unknown, takes: readonly string[] = []): Promise<unknown> =>
|
|
206
|
+
withinTime(claude.clock, method, options, run).catch((error: unknown) => {
|
|
207
|
+
if (error instanceof PassedTimeout && !method.startsWith('before')) return undefined
|
|
208
|
+
report(`session:${method}`, `The ${method} listener failed`, error)
|
|
209
|
+
return options.onFailure === 'refuse' && takes.includes('refuse') ? { refuse: `${name}: ${messageOf(error)}` } : undefined
|
|
210
|
+
})
|
|
211
|
+
|
|
200
212
|
const combined = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<Partial<Answers> | undefined> => {
|
|
201
213
|
const askers = [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])]
|
|
202
214
|
.filter((entry) => entry.event === event)
|
|
@@ -204,15 +216,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
204
216
|
if (!isInScope(entry.scope, session, latest, isUnderIn(session))) return undefined
|
|
205
217
|
if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
|
|
206
218
|
const payload = await payloadFor(session, latest)
|
|
207
|
-
const answer = await
|
|
208
|
-
if (entry.options.onFailure !== 'refuse') throw error
|
|
209
|
-
report(`session:${entry.method}`, `The ${entry.method} listener failed`, error)
|
|
210
|
-
return { refuse: `${name}: ${messageOf(error)}` }
|
|
211
|
-
})
|
|
219
|
+
const answer = await invoke(entry.method, entry.options, async (time) => takenAnswer(entry.method, beforeAnswerKeys[event], await entry.listener(payload as never, time)), beforeAnswerKeys[event])
|
|
212
220
|
if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
|
|
213
221
|
throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
|
|
214
222
|
})
|
|
215
|
-
return
|
|
223
|
+
return foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
const eventMethods = (scopeOf: () => Scope, hasBefore: boolean, ended?: string): OnMethods & Partial<BeforeMethods> => {
|
|
@@ -226,9 +234,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
226
234
|
]) as OnMethods & Partial<BeforeMethods>
|
|
227
235
|
}
|
|
228
236
|
|
|
237
|
+
let folders: Promise<HomeFolders> | undefined
|
|
229
238
|
const withFiles = async (message: Message): Promise<Message> => {
|
|
230
239
|
if (message.type !== ToolCall && message.type !== ToolResult) return message
|
|
231
|
-
|
|
240
|
+
folders ??= Promise.all([claude.env.home(), claude.env.configHome()]).then(([home, configRoot]) => ({ home, configRoot }))
|
|
241
|
+
return { ...message, files: await callFiles(name, claude, folders, { tool: message.name, input: message.input }, message.cwd) }
|
|
232
242
|
}
|
|
233
243
|
|
|
234
244
|
const messagesFrom = async (method: string, input: unknown) => Promise.all((await call<Message[]>(method, input)).map(withFiles))
|
|
@@ -426,39 +436,17 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
426
436
|
const processListeners = new Map<string, Set<ProcessListener>>()
|
|
427
437
|
|
|
428
438
|
const raise = async (method: string, payload: HookFields): Promise<HookFields | undefined> => {
|
|
429
|
-
const isBefore = method.startsWith('before')
|
|
430
439
|
const heard = [...(processListeners.get(method) ?? [])].filter(({ matcher }) => payload['task'] === undefined && (matcher === undefined || matches(payload, matcher)))
|
|
431
|
-
if (!
|
|
432
|
-
await Promise.all(
|
|
433
|
-
heard.map(({ listener, options }) =>
|
|
434
|
-
withinTime(claude.clock, method, options, (time) => listener(payload, time)).catch((error: unknown) => {
|
|
435
|
-
if (!(error instanceof PassedTimeout)) report(`session:${method}`, `The ${method} listener failed`, error)
|
|
436
|
-
}),
|
|
437
|
-
),
|
|
438
|
-
)
|
|
440
|
+
if (!method.startsWith('before')) {
|
|
441
|
+
await Promise.all(heard.map(({ listener, options }) => invoke(method, options, (time) => listener(payload, time))))
|
|
439
442
|
return undefined
|
|
440
443
|
}
|
|
444
|
+
const failing = method === 'beforePermissionRequest' ? { onFailure: 'refuse' as const } : {}
|
|
441
445
|
const takes = answerKeys[method.slice('before'.length)] ?? []
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
try {
|
|
445
|
-
const answer = (await withinTime(claude.clock, method, options, (time) => listener(payload, time))) as HookFields | null | undefined
|
|
446
|
-
if (answer === undefined || answer === null) continue
|
|
447
|
-
const unread = Object.keys(answer).filter((key) => !takes.includes(key))
|
|
448
|
-
if (unread.length > 0) throw new Error(`it answered ${unread.join(' and ')}, which ${method} does not take. It takes ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
|
|
449
|
-
const notes = [merged?.['note'], answer['note']].filter((note): note is string => typeof note === 'string')
|
|
450
|
-
merged = { ...merged, ...answer, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
|
|
451
|
-
if (typeof answer['refuse'] === 'string') return merged
|
|
452
|
-
} catch (error) {
|
|
453
|
-
report(`session:${method}`, `The ${method} listener failed`, error)
|
|
454
|
-
if (options.onFailure === 'refuse' || method === 'beforePermissionRequest') return { refuse: `${name}: ${messageOf(error)}` }
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
return merged
|
|
446
|
+
const askers = heard.map(({ listener, options }) => () => invoke(method, { ...options, ...failing }, async (time) => takenAnswer(method, takes, await listener(payload, time)), takes))
|
|
447
|
+
return foldAnswers<undefined, HookFields>(method, takes, ['refuse'], undefined, askers, (error) => report(`session:${method}`, `The ${method} listener failed`, error))
|
|
458
448
|
}
|
|
459
449
|
|
|
460
|
-
const permissionCheck = (e: ToolCheck, below: ToolCheckVerdict): HookFields => ({ session: current.id, cwd: current.cwd, ...defined({ task: e.agentId, callId: e.tool_use_id, reason: below.reason, rule: below.rule, ceiling: e.ceiling === undefined ? undefined : verdictName(e.ceiling) }), name: e.tool, input: recordOf(e.input), decision: verdictName(below.decision) })
|
|
461
|
-
|
|
462
450
|
const commandRun = async (e: Args<'command.run'>): Promise<HookFields> => {
|
|
463
451
|
const source = (await claude.command.list()).find((command) => command.name === e.command)?.source
|
|
464
452
|
return { session: current.id, cwd: current.cwd, name: e.command, args: e.args, ...defined({ source }), from: senderOf(e.origin, false) }
|
|
@@ -483,30 +471,21 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
483
471
|
const listenTo = (method: string, named: string) => {
|
|
484
472
|
if (hookOn === undefined || routeOn === undefined) throw new Error(`${name}: ${method} works only in a mod's own session.`)
|
|
485
473
|
const isBefore = method.startsWith('before')
|
|
486
|
-
if (named === 'PermissionCheck') {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
void raise(method, permissionCheck(e, below))
|
|
491
|
-
return below
|
|
492
|
-
}
|
|
493
|
-
const answer = await raise(method, permissionCheck(e, below))
|
|
494
|
-
if (answer === undefined || below.decision === 'deny') return below
|
|
495
|
-
if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
|
|
496
|
-
if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
|
|
497
|
-
return answer['allow'] === true ? { decision: 'allow' } : below
|
|
498
|
-
})
|
|
474
|
+
if (named === 'PermissionCheck' || named === 'PermissionRequest') {
|
|
475
|
+
const routes = permissionRoutes?.()
|
|
476
|
+
if (routes === undefined) throw new Error(withoutPermissionCheck(name, method))
|
|
477
|
+
return routes(method, { raise, hookOn, routeOn, current: () => current })
|
|
499
478
|
}
|
|
500
479
|
const event: { readonly on: string; readonly before?: string } = processEvents[named as ProcessName]
|
|
501
480
|
const hook = (isBefore ? event.before : undefined) ?? event.on
|
|
502
481
|
return hookOn(hook, async (input: unknown) => {
|
|
503
|
-
const payload = processPayloadOf(named as
|
|
482
|
+
const payload = processPayloadOf(named as ProcessFieldsName, input)
|
|
504
483
|
if (!isBefore) {
|
|
505
484
|
if (named !== 'SessionStart' || payload['source'] !== 'compact') void raise(method, payload)
|
|
506
485
|
return undefined
|
|
507
486
|
}
|
|
508
487
|
const answer = await raise(method, payload)
|
|
509
|
-
return answer === undefined ? undefined : hookAnswerOf(named,
|
|
488
|
+
return answer === undefined ? undefined : hookAnswerOf(named, answer)
|
|
510
489
|
})
|
|
511
490
|
}
|
|
512
491
|
|
|
@@ -550,14 +529,14 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
550
529
|
},
|
|
551
530
|
changed(session, change, isInProject) {
|
|
552
531
|
remember(session, change)
|
|
532
|
+
let payload: Promise<unknown> | undefined
|
|
553
533
|
for (const key of isInProject ? [session, everySession] : [session]) {
|
|
554
534
|
for (const { scope, event, method, matcher, listener, options } of listening.get(key) ?? []) {
|
|
555
535
|
if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session))) continue
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
.
|
|
559
|
-
|
|
560
|
-
})
|
|
536
|
+
payload ??= payloadFor(session, change)
|
|
537
|
+
void payload
|
|
538
|
+
.then((heard) => invoke(method, options, (time) => listener(heard as never, time)))
|
|
539
|
+
.catch((error: unknown) => report(`session:${method}`, `The ${method} listener failed`, error))
|
|
561
540
|
}
|
|
562
541
|
}
|
|
563
542
|
},
|
|
@@ -583,22 +562,16 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
583
562
|
}
|
|
584
563
|
}
|
|
585
564
|
|
|
586
|
-
type
|
|
587
|
-
|
|
588
|
-
type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
|
|
589
|
-
|
|
590
|
-
type ToolCheckVerdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?: string; readonly rule?: string }
|
|
565
|
+
export type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
|
|
591
566
|
|
|
592
567
|
export type RouteOn = {
|
|
593
568
|
(event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>): Disposable
|
|
594
569
|
(event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>): Disposable
|
|
595
570
|
}
|
|
596
571
|
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
|
|
572
|
+
export const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
|
|
600
573
|
|
|
601
|
-
const defined = (fields: HookFields): HookFields => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
|
|
574
|
+
export const defined = (fields: HookFields): HookFields => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
|
|
602
575
|
|
|
603
576
|
const placementOf = (input: HookFields): Placement => defined({ session: input['session_id'], task: input['agent_id'], agentType: input['agent_type'], cwd: input['cwd'], permissionMode: input['permission_mode'] }) as Placement
|
|
604
577
|
|
|
@@ -635,7 +608,9 @@ const answerKeys: Readonly<Record<string, readonly string[]>> = {
|
|
|
635
608
|
|
|
636
609
|
const todoOf = (input: HookFields) => ({ id: input['task_id'], subject: input['task_subject'], description: input['task_description'], teammate: input['teammate_name'] })
|
|
637
610
|
|
|
638
|
-
|
|
611
|
+
type ProcessFieldsName = Exclude<ProcessName, 'PermissionCheck'>
|
|
612
|
+
|
|
613
|
+
const processFields: { readonly [Name in ProcessFieldsName]: (input: HookFields) => HookFields } = {
|
|
639
614
|
ProjectSetup: (input) => ({ trigger: input['trigger'] }),
|
|
640
615
|
SessionStart: (input) => ({
|
|
641
616
|
source: input['source'],
|
|
@@ -683,28 +658,24 @@ export async function spawnTask(claude: Pick<Claude, 'agent'>, task: TaskStartAr
|
|
|
683
658
|
return { id: started.agentId, title: task.title, agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() }
|
|
684
659
|
}
|
|
685
660
|
|
|
686
|
-
export function processPayloadOf(named:
|
|
661
|
+
export function processPayloadOf(named: ProcessFieldsName, input: unknown): HookFields {
|
|
687
662
|
const fields = recordOf(input)
|
|
688
663
|
return { ...placementOf(fields), ...defined(processFields[named](fields)) }
|
|
689
664
|
}
|
|
690
665
|
|
|
691
|
-
|
|
692
|
-
const output = (fields: HookFields): HookAnswer => ({ hookSpecificOutput: { hookEventName: hook as Hook, ...defined(fields) } })
|
|
666
|
+
function hookAnswerOf(named: string, answer: HookFields): HookFields | undefined {
|
|
693
667
|
const refuse = typeof answer['refuse'] === 'string' ? answer['refuse'] : undefined
|
|
694
|
-
|
|
668
|
+
const note = typeof answer['note'] === 'string' ? [answer['note']] : undefined
|
|
669
|
+
if (named === 'SessionStart') return defined({ additionalContext: note, sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'] === true ? true : undefined, initialUserMessage: answer['firstPrompt'] })
|
|
695
670
|
if (named === 'ModelChange') {
|
|
696
|
-
if (refuse !== undefined) return
|
|
697
|
-
return typeof answer['ask'] === 'string' ?
|
|
698
|
-
}
|
|
699
|
-
if (named === 'WorktreeCreate') return output({ worktreePath: answer['result'] })
|
|
700
|
-
if (named === 'PermissionRequest') {
|
|
701
|
-
if (refuse !== undefined) return output({ decision: defined({ behavior: 'deny', message: refuse, interrupt: answer['interrupt'] }) })
|
|
702
|
-
return answer['allow'] === true ? output({ decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) }) : undefined
|
|
671
|
+
if (refuse !== undefined) return { permissionDecision: 'deny', permissionDecisionReason: refuse }
|
|
672
|
+
return typeof answer['ask'] === 'string' ? { permissionDecision: 'ask', permissionDecisionReason: answer['ask'] } : undefined
|
|
703
673
|
}
|
|
704
|
-
if (named === '
|
|
705
|
-
if (named === '
|
|
706
|
-
if (named === '
|
|
707
|
-
|
|
674
|
+
if (named === 'WorktreeCreate') return defined({ worktreePath: answer['result'] })
|
|
675
|
+
if (named === 'PermissionDenial') return answer['retry'] === true ? { retry: true } : undefined
|
|
676
|
+
if (named === 'ToolBatch') return defined({ additionalContext: note })
|
|
677
|
+
if (named === 'ReplyDisplay') return defined({ displayContent: answer['text'] })
|
|
678
|
+
return refuse === undefined ? undefined : { block: refuse }
|
|
708
679
|
}
|
|
709
680
|
|
|
710
681
|
export function resultOf(where: Pick<Place, 'session' | 'context' | 'turn'>, call: ToolCallArgs & { readonly cwd: string }, answered: Awaited<ReturnType<Claude['tool']['call']>>): ToolResult {
|
package/src/runtime/state.ts
CHANGED
|
@@ -84,14 +84,19 @@ export function createState<State extends object>({ name, initial, session, root
|
|
|
84
84
|
|
|
85
85
|
const forgetEndedSessions = async (current: string) => {
|
|
86
86
|
const stored = await claude.store.keys()
|
|
87
|
-
|
|
88
|
-
const owner = recorded.slice(transcriptsKey.length)
|
|
89
|
-
if (owner === current) continue
|
|
87
|
+
const ended = async (recorded: string) => {
|
|
90
88
|
const transcript = await claude.store.get(recorded)
|
|
91
|
-
|
|
92
|
-
for (const value of stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`))) await claude.store.delete(value)
|
|
93
|
-
await claude.store.delete(recorded)
|
|
89
|
+
return typeof transcript !== 'string' || !(await claude.fs.exists(transcript))
|
|
94
90
|
}
|
|
91
|
+
const owners = stored.filter((key) => key.startsWith(transcriptsKey) && key !== `${transcriptsKey}${current}`)
|
|
92
|
+
await Promise.all(
|
|
93
|
+
owners.map(async (recorded) => {
|
|
94
|
+
if (!(await ended(recorded))) return
|
|
95
|
+
const owner = recorded.slice(transcriptsKey.length)
|
|
96
|
+
await Promise.all(stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`)).map((value) => claude.store.delete(value)))
|
|
97
|
+
await claude.store.delete(recorded)
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
const loadGroups = async (only: readonly Lifetime[], next: { readonly root: string; readonly session: string }) => {
|
|
@@ -151,8 +156,10 @@ export function createState<State extends object>({ name, initial, session, root
|
|
|
151
156
|
moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId }))),
|
|
152
157
|
switchSession: (nextSession, source, transcript) =>
|
|
153
158
|
inTurn(async () => {
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
if (Object.keys(declared.session).length > 0) {
|
|
160
|
+
await claude.store.set(`${transcriptsKey}${nextSession}`, transcript)
|
|
161
|
+
void forgetEndedSessions(nextSession).catch((error: unknown) => report({ id: 'state:sessions', message: 'Could not forget the session values of ended sessions', error }))
|
|
162
|
+
}
|
|
156
163
|
if (nextSession === sessionId) return
|
|
157
164
|
if (source === 'fork') await copySession(sessionId, nextSession)
|
|
158
165
|
return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession })
|
|
@@ -50,7 +50,11 @@ export const skillPrefix = 'Base directory for this skill:'
|
|
|
50
50
|
|
|
51
51
|
const rowTypes: readonly string[] = ['user', 'assistant', 'attachment', 'system']
|
|
52
52
|
|
|
53
|
-
const memoryPath = /^Contents of (.+?) \(
|
|
53
|
+
const memoryPath = /^Contents of (.+?)(?: \([^)\n]*\))?:\r?$/m
|
|
54
|
+
|
|
55
|
+
export function instructionPathOf(text: string): string {
|
|
56
|
+
return memoryPath.exec(text)?.[1] ?? ''
|
|
57
|
+
}
|
|
54
58
|
|
|
55
59
|
export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefined {
|
|
56
60
|
const { uuid, timestamp, type } = row
|
|
@@ -77,7 +81,7 @@ export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefin
|
|
|
77
81
|
export function rowOfAppend(append: { readonly uuid: string; readonly door: string; readonly origin: unknown; readonly message: { readonly type: Row['type']; readonly name?: string; readonly isMeta?: true; readonly content: readonly unknown[] } }, at: string, cwd: string): Row {
|
|
78
82
|
const { message, origin } = append
|
|
79
83
|
const text = textOf(message.content)
|
|
80
|
-
const path = message.type === 'attachment' ?
|
|
84
|
+
const path = message.type === 'attachment' ? instructionPathOf(text) || undefined : undefined
|
|
81
85
|
const model = isObject(origin) && origin['kind'] === 'model' && typeof origin['model'] === 'string' ? origin['model'] : undefined
|
|
82
86
|
return {
|
|
83
87
|
id: append.uuid,
|
package/src/testing.ts
CHANGED
|
@@ -23,7 +23,8 @@ import type { Options, OptionValues } from './options.js'
|
|
|
23
23
|
import { readSteps, storeFolder, type Steps } from './records.js'
|
|
24
24
|
import type { Claude } from './runtime/claude.js'
|
|
25
25
|
import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
|
|
26
|
-
import {
|
|
26
|
+
import { closingContext, foldBefore, installBefore, withAnswer } from './runtime/before.js'
|
|
27
|
+
import { permissionRoutes } from './runtime/permission-check.js'
|
|
27
28
|
import type { RoutedEvent } from './runtime/hooks.js'
|
|
28
29
|
import { keepClaudeCode, type ClaudeCode } from './testing/claude-code.js'
|
|
29
30
|
import { createRouter } from './runtime/router.js'
|
|
@@ -74,12 +75,14 @@ type MessageBefore = Exclude<BeforeName, 'TaskStart' | 'TurnEnd' | 'ContextEnd'>
|
|
|
74
75
|
|
|
75
76
|
type DraftOf<Name extends MessageBefore> = Omit<Extract<MessageDraft, { readonly type: (typeof sessionEvents)[Name] }>, 'type'>
|
|
76
77
|
|
|
78
|
+
type RaisedOf<Name extends MessageBefore> = Name extends 'ToolResult' ? Omit<DraftOf<Name>, 'output'> & { readonly output: unknown } : DraftOf<Name>
|
|
79
|
+
|
|
77
80
|
export type Raise = {
|
|
78
|
-
readonly [Name in MessageBefore as `before${Name}`]: (message:
|
|
81
|
+
readonly [Name in MessageBefore as `before${Name}`]: (message: RaisedOf<Name>, options?: { readonly task?: string }) => Promise<AnswerOf<Name> | undefined>
|
|
79
82
|
} & {
|
|
80
83
|
beforeTaskStart(task: { readonly title: string; readonly agentType?: string; readonly model?: string }): Promise<AnswerOf<'TaskStart'> | undefined>
|
|
81
84
|
beforeTurnEnd(ending?: Pick<Turn, 'answer' | 'failure'>): Promise<AnswerOf<'TurnEnd'> | undefined>
|
|
82
|
-
beforeContextEnd(): Promise<AnswerOf<'ContextEnd'> | undefined>
|
|
85
|
+
beforeContextEnd(options?: { readonly task?: string }): Promise<AnswerOf<'ContextEnd'> | undefined>
|
|
83
86
|
} & {
|
|
84
87
|
readonly [Name in ProcessName as `on${Name}`]: (payload: Placed<ProcessPayload[Name]>) => Promise<void>
|
|
85
88
|
} & {
|
|
@@ -174,7 +177,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
174
177
|
const starting = options.state as Record<string, object> | undefined
|
|
175
178
|
const declared = Object.entries(definition.state ?? {}) as [string, object][]
|
|
176
179
|
const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }])) as State
|
|
177
|
-
const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () =>
|
|
180
|
+
const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () => permissionRoutes,(options.options ?? {}) as PluginOptions, false)
|
|
178
181
|
let started: Promise<void> | undefined
|
|
179
182
|
|
|
180
183
|
const start = async () => {
|
|
@@ -212,7 +215,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
212
215
|
hooks: {
|
|
213
216
|
place: async () => conversations.place(),
|
|
214
217
|
wants: async (event) => conversations.wants(name, event),
|
|
215
|
-
decide: (event, change) =>
|
|
218
|
+
decide: (event, change) => foldBefore(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
|
|
216
219
|
},
|
|
217
220
|
})
|
|
218
221
|
|
|
@@ -276,9 +279,11 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
276
279
|
const raise = Object.fromEntries([
|
|
277
280
|
...beforeEvents
|
|
278
281
|
.filter((named) => named !== 'TaskStart' && named !== 'TurnEnd' && named !== 'ContextEnd')
|
|
279
|
-
.map((named) => [`before${named}`, async (draft:
|
|
282
|
+
.map((named) => [`before${named}`, async (draft: { readonly output?: unknown }, placed?: { readonly task?: string }) => {
|
|
280
283
|
await start()
|
|
281
|
-
|
|
284
|
+
const isResult = named === 'ToolResult'
|
|
285
|
+
const message = conversations.draft({ ...draft, ...(isResult ? { output: '' } : {}), type: sessionEvents[named] } as MessageDraft, placed?.task)
|
|
286
|
+
return asked(sessionEvents[named], withAnswer({ type: Message, message }, { output: draft.output }))
|
|
282
287
|
}]),
|
|
283
288
|
['beforeTaskStart', async ({ title, agentType = 'general-purpose', model }: { readonly title: string; readonly agentType?: string; readonly model?: string }) => {
|
|
284
289
|
await start()
|
|
@@ -290,10 +295,12 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
290
295
|
if (turn === undefined) throw new Error(`${name} has no turn open to end. Open one with tested.add({ type: Prompt, text: '…' }).`)
|
|
291
296
|
return asked(TurnEnd, { type: TurnEnd, turn: { ...turn, ...ending, closedAt: new Date().toISOString() } })
|
|
292
297
|
}],
|
|
293
|
-
['beforeContextEnd', async () => {
|
|
298
|
+
['beforeContextEnd', async (placed: { readonly task?: string } = {}) => {
|
|
294
299
|
await start()
|
|
295
|
-
const
|
|
296
|
-
|
|
300
|
+
const where = conversations.place()
|
|
301
|
+
const task = placed.task === undefined ? undefined : where.tasks[placed.task]
|
|
302
|
+
if (placed.task !== undefined && task === undefined) throw new Error(`${name} has no task ${placed.task}. Start it with tested.startTask.`)
|
|
303
|
+
return asked(ContextEnd, { type: ContextEnd, context: closingContext(where, task, new Date().toISOString()) })
|
|
297
304
|
}],
|
|
298
305
|
...[...Object.entries(processEvents).flatMap(([named, event]) => [`on${named}`, ...('before' in event ? [`before${named}`] : [])]), 'onRun', 'beforeRun'].map((method) => [method, async (payload: object) => {
|
|
299
306
|
await start()
|
package/testing.d.ts
CHANGED
|
@@ -38,8 +38,11 @@ type MessageBefore = Exclude<BeforeName, 'TaskStart' | 'TurnEnd' | 'ContextEnd'>
|
|
|
38
38
|
type DraftOf<Name extends MessageBefore> = Omit<Extract<MessageDraft, {
|
|
39
39
|
readonly type: (typeof sessionEvents)[Name];
|
|
40
40
|
}>, 'type'>;
|
|
41
|
+
type RaisedOf<Name extends MessageBefore> = Name extends 'ToolResult' ? Omit<DraftOf<Name>, 'output'> & {
|
|
42
|
+
readonly output: unknown;
|
|
43
|
+
} : DraftOf<Name>;
|
|
41
44
|
export type Raise = {
|
|
42
|
-
readonly [Name in MessageBefore as `before${Name}`]: (message:
|
|
45
|
+
readonly [Name in MessageBefore as `before${Name}`]: (message: RaisedOf<Name>, options?: {
|
|
43
46
|
readonly task?: string;
|
|
44
47
|
}) => Promise<AnswerOf<Name> | undefined>;
|
|
45
48
|
} & {
|
|
@@ -49,7 +52,9 @@ export type Raise = {
|
|
|
49
52
|
readonly model?: string;
|
|
50
53
|
}): Promise<AnswerOf<'TaskStart'> | undefined>;
|
|
51
54
|
beforeTurnEnd(ending?: Pick<Turn, 'answer' | 'failure'>): Promise<AnswerOf<'TurnEnd'> | undefined>;
|
|
52
|
-
beforeContextEnd(
|
|
55
|
+
beforeContextEnd(options?: {
|
|
56
|
+
readonly task?: string;
|
|
57
|
+
}): Promise<AnswerOf<'ContextEnd'> | undefined>;
|
|
53
58
|
} & {
|
|
54
59
|
readonly [Name in ProcessName as `on${Name}`]: (payload: Placed<ProcessPayload[Name]>) => Promise<void>;
|
|
55
60
|
} & {
|
package/testing.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["src/testing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EAEJ,gBAAgB,EAGhB,WAAW,EASX,eAAe,EAGhB,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["src/testing.ts"],"names":[],"mappings":"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"}
|
package/testing.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readSteps, storeFolder } from './records.js';
|
|
2
2
|
import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js';
|
|
3
|
-
import {
|
|
3
|
+
import { closingContext, foldBefore, installBefore, withAnswer } from './runtime/before.js';
|
|
4
|
+
import { permissionRoutes } from './runtime/permission-check.js';
|
|
4
5
|
import { keepClaudeCode } from './testing/claude-code.js';
|
|
5
6
|
import { createRouter } from './runtime/router.js';
|
|
6
7
|
import { toolInputOf } from './runtime/tool-calls.js';
|
|
@@ -79,7 +80,7 @@ export function testMod(definition, options = {}) {
|
|
|
79
80
|
const starting = options.state;
|
|
80
81
|
const declared = Object.entries(definition.state ?? {});
|
|
81
82
|
const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }]));
|
|
82
|
-
const lifecycle = createLifecycle({ ...definition, state }, () =>
|
|
83
|
+
const lifecycle = createLifecycle({ ...definition, state }, () => permissionRoutes, (options.options ?? {}), false);
|
|
83
84
|
let started;
|
|
84
85
|
const start = async () => {
|
|
85
86
|
started ??= lifecycle.start(fake.claude, async (claude) => {
|
|
@@ -116,7 +117,7 @@ export function testMod(definition, options = {}) {
|
|
|
116
117
|
hooks: {
|
|
117
118
|
place: async () => conversations.place(),
|
|
118
119
|
wants: async (event) => conversations.wants(name, event),
|
|
119
|
-
decide: (event, change) =>
|
|
120
|
+
decide: (event, change) => foldBefore(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
|
|
120
121
|
},
|
|
121
122
|
});
|
|
122
123
|
const route = async (event, input, below) => {
|
|
@@ -178,7 +179,9 @@ export function testMod(definition, options = {}) {
|
|
|
178
179
|
.filter((named) => named !== 'TaskStart' && named !== 'TurnEnd' && named !== 'ContextEnd')
|
|
179
180
|
.map((named) => [`before${named}`, async (draft, placed) => {
|
|
180
181
|
await start();
|
|
181
|
-
|
|
182
|
+
const isResult = named === 'ToolResult';
|
|
183
|
+
const message = conversations.draft({ ...draft, ...(isResult ? { output: '' } : {}), type: sessionEvents[named] }, placed?.task);
|
|
184
|
+
return asked(sessionEvents[named], withAnswer({ type: Message, message }, { output: draft.output }));
|
|
182
185
|
}]),
|
|
183
186
|
['beforeTaskStart', async ({ title, agentType = 'general-purpose', model }) => {
|
|
184
187
|
await start();
|
|
@@ -191,10 +194,13 @@ export function testMod(definition, options = {}) {
|
|
|
191
194
|
throw new Error(`${name} has no turn open to end. Open one with tested.add({ type: Prompt, text: '…' }).`);
|
|
192
195
|
return asked(TurnEnd, { type: TurnEnd, turn: { ...turn, ...ending, closedAt: new Date().toISOString() } });
|
|
193
196
|
}],
|
|
194
|
-
['beforeContextEnd', async () => {
|
|
197
|
+
['beforeContextEnd', async (placed = {}) => {
|
|
195
198
|
await start();
|
|
196
|
-
const
|
|
197
|
-
|
|
199
|
+
const where = conversations.place();
|
|
200
|
+
const task = placed.task === undefined ? undefined : where.tasks[placed.task];
|
|
201
|
+
if (placed.task !== undefined && task === undefined)
|
|
202
|
+
throw new Error(`${name} has no task ${placed.task}. Start it with tested.startTask.`);
|
|
203
|
+
return asked(ContextEnd, { type: ContextEnd, context: closingContext(where, task, new Date().toISOString()) });
|
|
198
204
|
}],
|
|
199
205
|
...[...Object.entries(processEvents).flatMap(([named, event]) => [`on${named}`, ...('before' in event ? [`before${named}`] : [])]), 'onRun', 'beforeRun'].map((method) => [method, async (payload) => {
|
|
200
206
|
await start();
|