@cmodjs/core 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +3 -3
- package/docs/dependencies.md +1 -1
- package/package.json +1 -1
- package/records.d.ts +1 -1
- package/records.js +1 -1
- package/register.d.ts.map +1 -1
- package/register.js +5 -4
- package/runtime/before.d.ts +6 -3
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +59 -37
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +38 -13
- package/runtime/hooks.d.ts +9 -22
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +4 -58
- package/runtime/lifecycle.d.ts +2 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +34 -17
- package/runtime/permission-check.d.ts +15 -0
- package/runtime/permission-check.d.ts.map +1 -0
- package/runtime/permission-check.js +37 -0
- package/runtime/sessions.d.ts +12 -8
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +41 -80
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +14 -11
- package/session/context/turn/message/message.d.ts +1 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +5 -2
- package/src/records.ts +1 -1
- package/src/register.ts +5 -4
- package/src/runtime/before.ts +60 -39
- package/src/runtime/conversations.ts +35 -13
- package/src/runtime/hooks.ts +13 -84
- package/src/runtime/lifecycle.ts +49 -31
- package/src/runtime/permission-check.ts +47 -0
- package/src/runtime/sessions.ts +51 -84
- package/src/runtime/state.ts +15 -8
- package/src/session/context/turn/message/message.ts +6 -2
- package/src/testing.ts +4 -3
- package/testing.d.ts.map +1 -1
- package/testing.js +4 -3
|
@@ -13,7 +13,10 @@ export const Message = 'message';
|
|
|
13
13
|
export const messageTypes = [Prompt, Note, Reply, ToolCall, ToolResult, Skill, InstructionFile, Notice];
|
|
14
14
|
export const skillPrefix = 'Base directory for this skill:';
|
|
15
15
|
const rowTypes = ['user', 'assistant', 'attachment', 'system'];
|
|
16
|
-
const memoryPath = /^Contents of (.+?) \(
|
|
16
|
+
const memoryPath = /^Contents of (.+?)(?: \([^)\n]*\))?:\r?$/m;
|
|
17
|
+
export function instructionPathOf(text) {
|
|
18
|
+
return memoryPath.exec(text)?.[1] ?? '';
|
|
19
|
+
}
|
|
17
20
|
export function rowOfLine(row) {
|
|
18
21
|
const { uuid, timestamp, type } = row;
|
|
19
22
|
if (typeof uuid !== 'string' || typeof timestamp !== 'string' || typeof type !== 'string' || !rowTypes.includes(type))
|
|
@@ -39,7 +42,7 @@ export function rowOfLine(row) {
|
|
|
39
42
|
export function rowOfAppend(append, at, cwd) {
|
|
40
43
|
const { message, origin } = append;
|
|
41
44
|
const text = textOf(message.content);
|
|
42
|
-
const path = message.type === 'attachment' ?
|
|
45
|
+
const path = message.type === 'attachment' ? instructionPathOf(text) || undefined : undefined;
|
|
43
46
|
const model = isObject(origin) && origin['kind'] === 'model' && typeof origin['model'] === 'string' ? origin['model'] : undefined;
|
|
44
47
|
return {
|
|
45
48
|
id: append.uuid,
|
package/src/records.ts
CHANGED
|
@@ -259,7 +259,7 @@ function readKeys(keys: unknown): Steps {
|
|
|
259
259
|
return { keys: commands }
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
export const coreVersion = '2.0.
|
|
262
|
+
export const coreVersion = '2.0.1'
|
|
263
263
|
|
|
264
264
|
export function oldestCmodFor(steps: Steps): string {
|
|
265
265
|
if (steps.permissions?.some((item) => item.startsWith('config:')) === true) return '1.6.0'
|
package/src/register.ts
CHANGED
|
@@ -5,10 +5,11 @@ import type { Claude } from './runtime/claude.js'
|
|
|
5
5
|
import type { RoutedEvent } from './runtime/hooks.js'
|
|
6
6
|
import { cmodPluginName } from './runtime/dependencies.js'
|
|
7
7
|
import { createLifecycle, readPlugin, type Lifecycle } from './runtime/lifecycle.js'
|
|
8
|
+
import { permissionRoutes, type PermissionRoutes } from './runtime/permission-check.js'
|
|
8
9
|
|
|
9
10
|
let lifecycle: Lifecycle<object>
|
|
10
11
|
|
|
11
|
-
let
|
|
12
|
+
let permissionCheck: PermissionRoutes | undefined
|
|
12
13
|
|
|
13
14
|
let registered: ModDefinition<object> | undefined
|
|
14
15
|
|
|
@@ -99,9 +100,9 @@ function routeCallToMod(_$: EngineInterface, eventInput: Frozen<Args<'cmod.call'
|
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
export function registerMod<State extends object, Declared extends Options>(addHook: On, definition: ModDefinition<State, string, Declared>, options: PluginOptions): void {
|
|
102
|
-
|
|
103
|
+
permissionCheck = undefined
|
|
103
104
|
registered = definition as ModDefinition<object>
|
|
104
|
-
lifecycle = createLifecycle(definition, () =>
|
|
105
|
+
lifecycle = createLifecycle(definition, () => permissionCheck, options)
|
|
105
106
|
addHook('session.start', startMod)
|
|
106
107
|
addHook('classic.Setup', routeToMod)
|
|
107
108
|
addHook('classic.SessionStart', routeToMod)
|
|
@@ -154,7 +155,7 @@ export function registerMod<State extends object, Declared extends Options>(addH
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
export function registerPermissionCheck(addHook: On): void {
|
|
157
|
-
|
|
158
|
+
permissionCheck = permissionRoutes
|
|
158
159
|
addHook('tool.check', routeToMod)
|
|
159
160
|
addHook('classic.PermissionRequest', routeToMod)
|
|
160
161
|
}
|
package/src/runtime/before.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Args, Frozen } from 'claude-code'
|
|
|
2
2
|
import type { Disposable } from '../mod.js'
|
|
3
3
|
import { ContextEnd } from '../session/context/context.js'
|
|
4
4
|
import { InstructionFile, isRulePath } from '../session/context/turn/message/instruction-file.js'
|
|
5
|
-
import { Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
|
|
5
|
+
import { instructionPathOf, Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
|
|
6
6
|
import { Note } from '../session/context/turn/message/note.js'
|
|
7
7
|
import { Notice } from '../session/context/turn/message/notice.js'
|
|
8
8
|
import { Prompt } from '../session/context/turn/message/prompt.js'
|
|
@@ -14,11 +14,12 @@ import { ToolResult } from '../session/context/turn/message/tool-result.js'
|
|
|
14
14
|
import { TurnEnd } from '../session/context/turn/turn.js'
|
|
15
15
|
import type { ConversationState, SessionChange, TaskData } from '../session/conversation.js'
|
|
16
16
|
import { Task, TaskStart } from '../session/goal/task.js'
|
|
17
|
-
import type
|
|
17
|
+
import { sessionEvents, type BeforeEvent, type SessionEvent } from '../session/session.js'
|
|
18
18
|
import type { Claude } from './claude.js'
|
|
19
19
|
import { cmodPluginName } from './dependencies.js'
|
|
20
20
|
import type { Router } from './router.js'
|
|
21
21
|
import { toolInputOf } from './tool-calls.js'
|
|
22
|
+
import { resolve } from '../vendor.js'
|
|
22
23
|
|
|
23
24
|
export type Place = Pick<ConversationState, 'session' | 'context' | 'contextOpenedAt' | 'turn' | 'tasks' | 'taskTurns' | 'openCalls'>
|
|
24
25
|
|
|
@@ -66,6 +67,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
66
67
|
}
|
|
67
68
|
const titles = new Map<string, string>()
|
|
68
69
|
const spawned = new Map<string, string>()
|
|
70
|
+
const agentFolders = new Map<string, string>()
|
|
69
71
|
const toolResultHook = (isFailure: boolean) => async (e: Readonly<Record<string, unknown>>, next: (e: never) => Promise<unknown>) => {
|
|
70
72
|
if (!(await wants(ToolResult))) return next(e as never)
|
|
71
73
|
const where = await place()
|
|
@@ -121,7 +123,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
121
123
|
router.add('prompt.attachment', async (e, next) => {
|
|
122
124
|
if (!(await wants(InstructionFile)) || e.type !== 'nested_memory') return next(e)
|
|
123
125
|
const where = await place()
|
|
124
|
-
const path =
|
|
126
|
+
const path = instructionPathOf(e.text)
|
|
125
127
|
const answer = await asked(InstructionFile, message(where, { id: '', at: now(), type: InstructionFile, path, text: e.text, isRule: isRulePath(path) }, e.agentId))
|
|
126
128
|
if (answer.refuse !== undefined) return { text: null }
|
|
127
129
|
return answer.text === undefined ? next(e) : { text: answer.text }
|
|
@@ -145,7 +147,8 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
145
147
|
if (!(await wants(ToolCall))) return next(e)
|
|
146
148
|
const where = await place()
|
|
147
149
|
const input = toolInputOf(e)
|
|
148
|
-
const
|
|
150
|
+
const cwd = (e.agentId === undefined ? undefined : agentFolders.get(e.agentId)) ?? (await claude.session.cwd())
|
|
151
|
+
const answer = await asked(ToolCall, message(where, { id: e.tool_use_id, at: now(), type: ToolCall, callId: e.tool_use_id, name: e.tool, input, cwd, files: noFiles }, e.agentId))
|
|
149
152
|
if (answer.refuse !== undefined) return { deny: answer.refuse }
|
|
150
153
|
if (answer.result !== undefined) return { result: answer.result }
|
|
151
154
|
if (answer.input === undefined) return next(e)
|
|
@@ -154,7 +157,13 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
154
157
|
}),
|
|
155
158
|
router.add('agent.spawn', async (e, next) => {
|
|
156
159
|
spawned.set(e.tool_use_id, e.prompt)
|
|
157
|
-
|
|
160
|
+
const start = async (args: typeof e) => {
|
|
161
|
+
const started = await next(args)
|
|
162
|
+
const folder = args.isTeammate === true ? undefined : (args.cwd ?? (args.parentAgentId === undefined ? undefined : agentFolders.get(args.parentAgentId)))
|
|
163
|
+
if (started.agentId !== undefined && folder !== undefined) agentFolders.set(started.agentId, resolve(await claude.session.cwd(), folder))
|
|
164
|
+
return started
|
|
165
|
+
}
|
|
166
|
+
if (!(await wants(TaskStart))) return start(e)
|
|
158
167
|
const where = await place()
|
|
159
168
|
const task: TaskData = { id: '', title: e.description, agentType: e.subagentType, model: e.model ?? e.parentModel, openedBy: e.tool_use_id, openedAt: now(), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }) }
|
|
160
169
|
const answer = await asked(TaskStart, { type: TaskStart, task: { ...task, context: where.context } })
|
|
@@ -162,7 +171,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
162
171
|
spawned.delete(e.tool_use_id)
|
|
163
172
|
return { deny: answer.refuse }
|
|
164
173
|
}
|
|
165
|
-
return
|
|
174
|
+
return start(answer.owner === undefined ? e : { ...e, model: answer.owner.model })
|
|
166
175
|
}),
|
|
167
176
|
router.add('classic.SubagentStart', async (e, next) => {
|
|
168
177
|
const where = await place()
|
|
@@ -177,12 +186,16 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
177
186
|
return withNote(await next(e), answer.note)
|
|
178
187
|
}),
|
|
179
188
|
router.add('classic.SubagentStop', async (e, next) => {
|
|
180
|
-
|
|
189
|
+
const stopped = <Answer extends { readonly block?: string }>(answer: Answer) => {
|
|
190
|
+
if (answer.block === undefined) agentFolders.delete(e.agent_id)
|
|
191
|
+
return answer
|
|
192
|
+
}
|
|
193
|
+
if (!(await wants(TurnEnd))) return stopped(await next(e))
|
|
181
194
|
const turn = (await place()).taskTurns[e.agent_id]
|
|
182
|
-
if (turn === undefined) return next(e)
|
|
195
|
+
if (turn === undefined) return stopped(await next(e))
|
|
183
196
|
const answer = await asked(TurnEnd, { type: TurnEnd, turn: { ...turn, closedAt: now() } })
|
|
184
197
|
const below = await next(e)
|
|
185
|
-
return answer.refuse === undefined ? below : { ...below, block: answer.refuse }
|
|
198
|
+
return stopped(answer.refuse === undefined ? below : { ...below, block: answer.refuse })
|
|
186
199
|
}),
|
|
187
200
|
router.add('classic.Stop', async (e, next) => {
|
|
188
201
|
if (!(await wants(TurnEnd))) return next(e)
|
|
@@ -213,41 +226,49 @@ export function withAnswer(change: SessionChange, answer: Partial<Answers>): Ses
|
|
|
213
226
|
return change
|
|
214
227
|
}
|
|
215
228
|
|
|
216
|
-
export
|
|
229
|
+
export const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
|
|
230
|
+
|
|
231
|
+
export const beforeAnswerKeys: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
|
|
232
|
+
prompt: ['text', 'note', 'title', 'refuse'],
|
|
233
|
+
note: ['text'],
|
|
234
|
+
skill: ['text'],
|
|
235
|
+
instructionFile: ['text', 'refuse'],
|
|
236
|
+
reply: ['text'],
|
|
237
|
+
toolCall: ['input', 'result', 'refuse'],
|
|
238
|
+
toolResult: ['output', 'isError', 'note'],
|
|
239
|
+
notice: ['text'],
|
|
240
|
+
taskStart: ['owner', 'refuse'],
|
|
241
|
+
turnEnd: ['refuse'],
|
|
242
|
+
contextEnd: ['refuse'],
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function foldBefore(event: BeforeEvent, change: SessionChange, askers: readonly ((change: SessionChange) => Promise<unknown>)[], report: (error: unknown) => void): Promise<Partial<Answers> | undefined> {
|
|
246
|
+
return foldAnswers(methodOf('before', event), beforeAnswerKeys[event], ['refuse', 'result'], change, askers, report, withAnswer)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function takenAnswer(method: string, takes: readonly string[], answer: unknown): unknown {
|
|
250
|
+
if (answer === undefined || answer === null || answer === false) return undefined
|
|
251
|
+
if (typeof answer !== 'object' || Array.isArray(answer)) throw new Error(`it answered ${JSON.stringify(answer)}, but ${method} takes an object or nothing: ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
|
|
252
|
+
const unread = Object.keys(answer).filter((key) => !takes.includes(key))
|
|
253
|
+
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'}.`)
|
|
254
|
+
return answer
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function foldAnswers<Change, Answer extends object>(method: string, takes: readonly string[], ends: readonly string[], change: Change, askers: readonly ((change: Change) => Promise<unknown>)[], report: (error: unknown) => void, applied: (change: Change, answer: Answer) => Change = (same) => same): Promise<Answer | undefined> {
|
|
217
258
|
let latest = change
|
|
218
|
-
let result:
|
|
259
|
+
let result: Record<string, unknown> | undefined
|
|
219
260
|
for (const ask of askers) {
|
|
220
261
|
try {
|
|
221
|
-
const answer =
|
|
222
|
-
if (answer ===
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
262
|
+
const answer = takenAnswer(method, takes, await ask(latest))
|
|
263
|
+
if (typeof answer !== 'object' || answer === null) continue
|
|
264
|
+
const given = answer as Record<string, unknown>
|
|
265
|
+
const notes = [result?.['note'], given['note']].filter((note) => typeof note === 'string')
|
|
266
|
+
result = { ...result, ...given, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
|
|
267
|
+
if (ends.some((key) => given[key] !== undefined)) return result as Answer
|
|
268
|
+
latest = applied(latest, answer as Answer)
|
|
227
269
|
} catch (error) {
|
|
228
270
|
report(error)
|
|
229
271
|
}
|
|
230
272
|
}
|
|
231
|
-
return result
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function answerOf(event: BeforeEvent, answer: unknown): Partial<Answers> | undefined {
|
|
235
|
-
if (typeof answer !== 'object' || answer === null) return undefined
|
|
236
|
-
const given = answer as Partial<Answers>
|
|
237
|
-
const allowed: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
|
|
238
|
-
prompt: ['text', 'note', 'title', 'refuse'],
|
|
239
|
-
note: ['text'],
|
|
240
|
-
skill: ['text'],
|
|
241
|
-
instructionFile: ['text', 'refuse'],
|
|
242
|
-
reply: ['text'],
|
|
243
|
-
toolCall: ['input', 'result', 'refuse'],
|
|
244
|
-
toolResult: ['output', 'isError', 'note'],
|
|
245
|
-
notice: ['text'],
|
|
246
|
-
taskStart: ['owner', 'refuse'],
|
|
247
|
-
turnEnd: ['refuse'],
|
|
248
|
-
contextEnd: ['refuse'],
|
|
249
|
-
}
|
|
250
|
-
const unread = Object.keys(given).filter((key) => !allowed[event].includes(key as keyof Answers))
|
|
251
|
-
if (unread.length > 0) throw new Error(`a before(${event}) listener answered ${unread.join(' and ')}, which ${event} does not take. It takes ${allowed[event].map((key) => `{ ${key} }`).join(', ')}.`)
|
|
252
|
-
return given
|
|
273
|
+
return result as Answer | undefined
|
|
253
274
|
}
|
|
@@ -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/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}`
|
|
@@ -54,61 +37,18 @@ export type RoutedEvent =
|
|
|
54
37
|
|
|
55
38
|
export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
|
|
56
39
|
|
|
57
|
-
type ClassicFields = Record<string, unknown>
|
|
58
|
-
|
|
59
40
|
type CallFiles = { readonly read: string[]; readonly changed: string[] }
|
|
60
41
|
|
|
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> {
|
|
42
|
+
export function classicHook<H extends Hook>(event: H, hook: HookListener<H>, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
|
|
99
43
|
const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
|
|
100
44
|
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)}` } }
|
|
45
|
+
.then(() => hook(e as ClassicHookInputs[H]))
|
|
46
|
+
.catch((error: unknown) => {
|
|
107
47
|
report({ id: `hook:${event}`, message: `The ${event} listener failed`, error })
|
|
108
48
|
return undefined
|
|
109
49
|
})
|
|
110
50
|
if (ours === undefined) return next(e)
|
|
111
|
-
return mergeClassic((await next(e)) as
|
|
51
|
+
return mergeClassic((await next(e)) as HookFields, ours)
|
|
112
52
|
}
|
|
113
53
|
return routed as unknown as RoutedHook<RoutedEvent>
|
|
114
54
|
}
|
|
@@ -134,9 +74,11 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
|
|
|
134
74
|
}
|
|
135
75
|
}
|
|
136
76
|
|
|
137
|
-
export
|
|
77
|
+
export type HomeFolders = { readonly home: string | undefined; readonly configRoot: string | undefined }
|
|
78
|
+
|
|
79
|
+
export async function callFiles(name: string, claude: Claude, folders: Promise<HomeFolders>, use: CallBase, cwd: string): Promise<CallFiles> {
|
|
138
80
|
try {
|
|
139
|
-
const
|
|
81
|
+
const { home, configRoot } = await folders
|
|
140
82
|
if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
|
|
141
83
|
const { shell, reads, writes } = callEffects(use, { cwd, home, ...(configRoot === undefined ? {} : { configRoot }), fs: claude.fs })
|
|
142
84
|
const known = (accesses: FileAccess[]) => accesses.map(({ path }) => path).filter((path) => shell === undefined || !dynamicPattern.test(path))
|
|
@@ -152,21 +94,8 @@ export async function callFiles(name: string, claude: Claude, use: CallBase, cwd
|
|
|
152
94
|
}
|
|
153
95
|
}
|
|
154
96
|
|
|
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 }
|
|
97
|
+
function mergeClassic(below: HookFields, ours: HookFields): HookFields {
|
|
98
|
+
const merged: Record<string, unknown> = { ...below, ...ours }
|
|
170
99
|
const context = [...((below['additionalContext'] as string[] | undefined) ?? []), ...((ours['additionalContext'] as string[] | undefined) ?? [])]
|
|
171
100
|
if (context.length > 0) merged['additionalContext'] = context
|
|
172
101
|
return merged
|