@cmodjs/core 1.6.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +7 -7
- package/docs/dependencies.md +1 -1
- package/docs/index.md +12 -11
- package/docs/jobs.md +12 -16
- package/docs/mod.md +111 -36
- package/docs/permissions.md +14 -18
- package/docs/session.md +171 -82
- package/docs/state.md +5 -5
- package/docs/testing.md +57 -41
- package/docs/ui.md +9 -9
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +1 -1
- package/jobs/prompt.d.ts +1 -3
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +2 -21
- package/jobs/slash-command.d.ts +1 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +1 -1
- package/jobs/status-line.d.ts +0 -1
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +3 -4
- package/mod.d.ts +53 -105
- package/mod.d.ts.map +1 -1
- package/mod.js +18 -80
- package/package.json +1 -1
- package/records.d.ts +1 -1
- package/records.js +1 -1
- package/register.d.ts.map +1 -1
- package/register.js +8 -5
- package/runtime/before.d.ts +11 -4
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +131 -67
- package/runtime/claude.d.ts +6 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +82 -38
- package/runtime/deadline.d.ts +17 -0
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +34 -0
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +12 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +13 -8
- package/runtime/hooks.d.ts +15 -11
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +8 -165
- package/runtime/lifecycle.d.ts +4 -2
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +96 -62
- package/runtime/permission-check.d.ts +15 -0
- package/runtime/permission-check.d.ts.map +1 -0
- package/runtime/permission-check.js +37 -0
- package/runtime/sessions.d.ts +35 -5
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +304 -82
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +14 -11
- package/runtime/tool-calls.d.ts +0 -1
- package/runtime/tool-calls.d.ts.map +1 -1
- package/runtime/tool-calls.js +0 -19
- package/session/context/turn/message/instruction-file.d.ts +9 -0
- package/session/context/turn/message/instruction-file.d.ts.map +1 -0
- package/session/context/turn/message/instruction-file.js +4 -0
- package/session/context/turn/message/message.d.ts +6 -4
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +12 -8
- package/session/context/turn/message/sender.d.ts +2 -1
- package/session/context/turn/message/sender.d.ts.map +1 -1
- package/session/context/turn/message/sender.js +4 -1
- package/session/context/turn/message/tool-call.d.ts +5 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -1
- package/session/context/turn/message/tool-result.d.ts +6 -1
- package/session/context/turn/message/tool-result.d.ts.map +1 -1
- package/session/context/turn/turn.d.ts +7 -0
- package/session/context/turn/turn.d.ts.map +1 -1
- package/session/conversation.d.ts +10 -3
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +14 -5
- package/session/goal/goal.d.ts +1 -1
- package/session/goal/goal.d.ts.map +1 -1
- package/session/goal/task.d.ts +2 -2
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +361 -26
- package/session/session.d.ts.map +1 -1
- package/session/session.js +40 -0
- package/src/jobs/permissions/decide-permission.ts +2 -1
- package/src/jobs/prompt.ts +3 -20
- package/src/jobs/slash-command.ts +2 -2
- package/src/jobs/status-line.ts +4 -7
- package/src/mod.ts +52 -147
- package/src/records.ts +1 -1
- package/src/register.ts +8 -5
- package/src/runtime/before.ts +140 -66
- package/src/runtime/claude.ts +5 -1
- package/src/runtime/conversations.ts +75 -37
- package/src/runtime/deadline.ts +38 -0
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/grants.ts +11 -7
- package/src/runtime/hooks.ts +24 -163
- package/src/runtime/lifecycle.ts +110 -77
- package/src/runtime/permission-check.ts +47 -0
- package/src/runtime/sessions.ts +334 -91
- package/src/runtime/state.ts +15 -8
- package/src/runtime/tool-calls.ts +0 -19
- package/src/session/context/turn/message/instruction-file.ts +13 -0
- package/src/session/context/turn/message/message.ts +17 -11
- package/src/session/context/turn/message/sender.ts +5 -2
- package/src/session/context/turn/message/tool-call.ts +3 -0
- package/src/session/context/turn/message/tool-result.ts +6 -1
- package/src/session/context/turn/turn.ts +4 -0
- package/src/session/conversation.ts +21 -8
- package/src/session/goal/goal.ts +1 -1
- package/src/session/goal/task.ts +2 -2
- package/src/session/session.ts +227 -24
- package/src/testing/claude-code.ts +23 -0
- package/src/testing/fake-claude.ts +5 -1
- package/src/testing/fake-sessions.ts +38 -24
- package/src/testing.ts +88 -19
- package/testing/claude-code.d.ts +12 -0
- package/testing/claude-code.d.ts.map +1 -0
- package/testing/claude-code.js +10 -0
- package/testing/fake-claude.d.ts +4 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +4 -1
- package/testing/fake-sessions.d.ts +8 -6
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +35 -20
- package/testing.d.ts +38 -9
- package/testing.d.ts.map +1 -1
- package/testing.js +58 -8
- package/docs/hooks.md +0 -259
- package/session/context/turn/message/document.d.ts +0 -7
- package/session/context/turn/message/document.d.ts.map +0 -1
- package/session/context/turn/message/document.js +0 -1
- package/src/session/context/turn/message/document.ts +0 -8
package/testing.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { readSteps, storeFolder } from './records.js';
|
|
2
2
|
import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js';
|
|
3
|
-
import {
|
|
3
|
+
import { foldBefore, installBefore } from './runtime/before.js';
|
|
4
|
+
import { permissionRoutes } from './runtime/permission-check.js';
|
|
5
|
+
import { keepClaudeCode } from './testing/claude-code.js';
|
|
4
6
|
import { createRouter } from './runtime/router.js';
|
|
5
7
|
import { toolInputOf } from './runtime/tool-calls.js';
|
|
6
8
|
import { createLifecycle } from './runtime/lifecycle.js';
|
|
7
9
|
import { MissingOptions } from './runtime/options.js';
|
|
8
10
|
import { conversationMethods } from './runtime/sessions.js';
|
|
11
|
+
import { Message } from './session/context/turn/message/message.js';
|
|
9
12
|
import { Note } from './session/context/turn/message/note.js';
|
|
10
13
|
import { Prompt } from './session/context/turn/message/prompt.js';
|
|
11
14
|
import { Mod, Person } from './session/context/turn/message/sender.js';
|
|
15
|
+
import { TurnEnd } from './session/context/turn/turn.js';
|
|
16
|
+
import { beforeEvents, processEvents, sessionEvents } from './session/session.js';
|
|
17
|
+
import { ContextEnd } from './session/context/context.js';
|
|
18
|
+
import { TaskStart } from './session/goal/task.js';
|
|
12
19
|
import { calledAnswer, fakeClaude } from './testing/fake-claude.js';
|
|
13
20
|
import { elements, findElement, rowsOf } from './testing/fake-elements.js';
|
|
14
21
|
import { fakeFiles } from './testing/fake-files.js';
|
|
@@ -37,6 +44,7 @@ export function testMod(definition, options = {}) {
|
|
|
37
44
|
const conversations = fakeSessions({
|
|
38
45
|
callTool: (args) => fake.claude.tool.call(args),
|
|
39
46
|
project: () => projectRoot,
|
|
47
|
+
cwd: () => cwd,
|
|
40
48
|
given: options.sessions ?? [],
|
|
41
49
|
push: (mod, session, change) => {
|
|
42
50
|
if (mod !== name)
|
|
@@ -72,7 +80,7 @@ export function testMod(definition, options = {}) {
|
|
|
72
80
|
const starting = options.state;
|
|
73
81
|
const declared = Object.entries(definition.state ?? {});
|
|
74
82
|
const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }]));
|
|
75
|
-
const lifecycle = createLifecycle({ ...definition, state }, () =>
|
|
83
|
+
const lifecycle = createLifecycle({ ...definition, state }, () => permissionRoutes, (options.options ?? {}), false);
|
|
76
84
|
let started;
|
|
77
85
|
const start = async () => {
|
|
78
86
|
started ??= lifecycle.start(fake.claude, async (claude) => {
|
|
@@ -109,7 +117,7 @@ export function testMod(definition, options = {}) {
|
|
|
109
117
|
hooks: {
|
|
110
118
|
place: async () => conversations.place(),
|
|
111
119
|
wants: async (event) => conversations.wants(name, event),
|
|
112
|
-
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' })),
|
|
113
121
|
},
|
|
114
122
|
});
|
|
115
123
|
const route = async (event, input, below) => {
|
|
@@ -162,6 +170,40 @@ export function testMod(definition, options = {}) {
|
|
|
162
170
|
return route(`classic.${event}`, filled, below);
|
|
163
171
|
return firePreToolUse(filled, below);
|
|
164
172
|
});
|
|
173
|
+
const asked = async (event, change) => {
|
|
174
|
+
await start();
|
|
175
|
+
return (await askMod(event, change)) ?? undefined;
|
|
176
|
+
};
|
|
177
|
+
const raise = Object.fromEntries([
|
|
178
|
+
...beforeEvents
|
|
179
|
+
.filter((named) => named !== 'TaskStart' && named !== 'TurnEnd' && named !== 'ContextEnd')
|
|
180
|
+
.map((named) => [`before${named}`, async (draft, placed) => {
|
|
181
|
+
await start();
|
|
182
|
+
return asked(sessionEvents[named], { type: Message, message: conversations.draft({ ...draft, type: sessionEvents[named] }, placed?.task) });
|
|
183
|
+
}]),
|
|
184
|
+
['beforeTaskStart', async ({ title, agentType = 'general-purpose', model }) => {
|
|
185
|
+
await start();
|
|
186
|
+
return asked(TaskStart, { type: TaskStart, task: { id: '', title, agentType, openedBy: '', openedAt: new Date().toISOString(), context: conversations.place().context, ...(model === undefined ? {} : { model }) } });
|
|
187
|
+
}],
|
|
188
|
+
['beforeTurnEnd', async (ending = {}) => {
|
|
189
|
+
await start();
|
|
190
|
+
const { turn } = conversations.place();
|
|
191
|
+
if (turn === undefined)
|
|
192
|
+
throw new Error(`${name} has no turn open to end. Open one with tested.add({ type: Prompt, text: '…' }).`);
|
|
193
|
+
return asked(TurnEnd, { type: TurnEnd, turn: { ...turn, ...ending, closedAt: new Date().toISOString() } });
|
|
194
|
+
}],
|
|
195
|
+
['beforeContextEnd', async () => {
|
|
196
|
+
await start();
|
|
197
|
+
const { context, contextOpenedAt } = conversations.place();
|
|
198
|
+
return asked(ContextEnd, { type: ContextEnd, context: { number: context, openedAt: contextOpenedAt, closedAt: new Date().toISOString() } });
|
|
199
|
+
}],
|
|
200
|
+
...[...Object.entries(processEvents).flatMap(([named, event]) => [`on${named}`, ...('before' in event ? [`before${named}`] : [])]), 'onRun', 'beforeRun'].map((method) => [method, async (payload) => {
|
|
201
|
+
await start();
|
|
202
|
+
const answer = await lifecycle.raise(method, { session: sessionId, cwd, ...payload });
|
|
203
|
+
await fake.settle();
|
|
204
|
+
return answer;
|
|
205
|
+
}]),
|
|
206
|
+
]);
|
|
165
207
|
const firePreToolUse = async ({ tool_name, tool_input, tool_use_id, agent_id, agent_type }, below) => {
|
|
166
208
|
await start();
|
|
167
209
|
if (agent_id !== undefined && agent_type !== undefined)
|
|
@@ -187,22 +229,28 @@ export function testMod(definition, options = {}) {
|
|
|
187
229
|
await fake.settle();
|
|
188
230
|
return value;
|
|
189
231
|
};
|
|
190
|
-
|
|
232
|
+
const tested = {
|
|
191
233
|
start,
|
|
234
|
+
raise,
|
|
192
235
|
fire,
|
|
193
236
|
settle: fake.settle,
|
|
194
237
|
add: async (message, added) => {
|
|
195
238
|
await start();
|
|
196
239
|
return settled(conversations.add(message, added?.task));
|
|
197
240
|
},
|
|
198
|
-
endTurn: async () => {
|
|
241
|
+
endTurn: async (ending) => {
|
|
199
242
|
await start();
|
|
200
|
-
await settled(conversations.endTurn());
|
|
243
|
+
await settled(conversations.endTurn(ending === undefined ? undefined : { ...ending, ...(ending.failure === undefined ? {} : { reason: 'error' }) }));
|
|
201
244
|
},
|
|
202
245
|
compact: async () => {
|
|
203
246
|
await start();
|
|
204
247
|
await settled(conversations.compact());
|
|
205
248
|
},
|
|
249
|
+
startSession: async ({ source, id }) => {
|
|
250
|
+
await start();
|
|
251
|
+
await fire('SessionStart', { source, ...(id === undefined ? {} : { session_id: id }) });
|
|
252
|
+
await fake.settle();
|
|
253
|
+
},
|
|
206
254
|
startTask: async (task) => {
|
|
207
255
|
await start();
|
|
208
256
|
return settled(conversations.startTask(task));
|
|
@@ -283,7 +331,7 @@ export function testMod(definition, options = {}) {
|
|
|
283
331
|
get state() {
|
|
284
332
|
const mod = lifecycle.mod;
|
|
285
333
|
if (mod === undefined)
|
|
286
|
-
throw new Error(`${name} has not started. Await tested.start()
|
|
334
|
+
throw new Error(`${name} has not started. Await tested.start() first.`);
|
|
287
335
|
return mod.state;
|
|
288
336
|
},
|
|
289
337
|
calls: fake.calls,
|
|
@@ -296,6 +344,8 @@ export function testMod(definition, options = {}) {
|
|
|
296
344
|
sent: conversations.sent,
|
|
297
345
|
},
|
|
298
346
|
};
|
|
347
|
+
keepClaudeCode(tested, { fire });
|
|
348
|
+
return tested;
|
|
299
349
|
}
|
|
300
350
|
function fakeDependencies(caller, dependencies, conversations) {
|
|
301
351
|
const cmod = { forkParent: () => null, updateSettings: () => null };
|
|
@@ -323,7 +373,7 @@ function plainOutput({ component, props }) {
|
|
|
323
373
|
return elements.Box({ flexDirection: 'column', children: [elements.Text({ children: component }), elements.Box({ flexDirection: 'column', paddingLeft: 2, children: rows })] });
|
|
324
374
|
}
|
|
325
375
|
function replyOf({ text, context }) {
|
|
326
|
-
return { ...(text === undefined ? {} : { text }), ...(context === undefined ? {} : {
|
|
376
|
+
return { ...(text === undefined ? {} : { text }), ...(context === undefined ? {} : { note: context.join('\n') }) };
|
|
327
377
|
}
|
|
328
378
|
async function modSteps() {
|
|
329
379
|
const manifest = '../../../package.json';
|
package/docs/hooks.md
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
# Hooks
|
|
2
|
-
|
|
3
|
-
A hook is a point where Claude Code asks its plugins, such as a prompt or a tool call. `mod.on` runs a listener at a hook. The listener reads the hook's input and may answer to change what Claude Code does next.
|
|
4
|
-
|
|
5
|
-
## mod.on
|
|
6
|
-
|
|
7
|
-
```ts
|
|
8
|
-
mod.on<H extends Hook>(hook: H, listener: HookListener<H>, options?: { timeoutMs?: number }): Disposable
|
|
9
|
-
mod.on<H extends Hook>(hook: H, matcher: Matcher<HookInput<H>>, listener: HookListener<H>, options?: { timeoutMs?: number }): Disposable
|
|
10
|
-
|
|
11
|
-
type HookListener<H> = (input: HookInput<H>) => HookAnswer | void | Promise<HookAnswer | void>
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
Add listeners in `setup`. Each `mod.on` adds one more listener. Several listeners on one hook all run. `dispose()` on the `Disposable` it returns removes the listener ([mod.md](mod.md#disposable)).
|
|
15
|
-
|
|
16
|
-
`mod.js` exports each hook's name as a constant, such as `PreToolUse`, and `mod.on` takes it: `mod.on(PreToolUse, …)`.
|
|
17
|
-
|
|
18
|
-
A matcher runs the listener only for the inputs it matches. It is a partial copy of the input: a key it names must match, and a key it leaves out matches anything. A string matches the same string, a `RegExp` matches the text it tests true on, and a list matches any one of its items.
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
mod.on(PostToolUse, { tool_name: /^(Edit|Write)$/ }, ({ files }) => void (mod.state.session.edited += files.changed.length))
|
|
22
|
-
mod.on(PreToolUse, { tool_name: 'Bash', tool_input: { command: /^rm / } }, () => ({ decision: 'block', reason: 'Use trash instead of rm.' }))
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
A listener that waits on something slow, such as a model call or a server, takes `timeoutMs`. Past it, the hook goes on as if the listener were absent, and cmod shows the warning toast `The <hook> hook passed its <n> s timeout`. Claude Code also gives a listener's own code 10 seconds, and the time it waits on a call does not count toward those 10 seconds, so `timeoutMs` is the limit on a slow call. `timeoutMs` is a whole number above 0 and at most `2147483647`, and any other value stops `setup` with the fix.
|
|
26
|
-
|
|
27
|
-
```ts
|
|
28
|
-
mod.on(Stop, async ({ last_assistant_message }) => {
|
|
29
|
-
const verdict = await model.complete({ model: 'haiku', prompt: `Is this reply complete? Answer yes or no.\n\n${last_assistant_message}` })
|
|
30
|
-
if (verdict.isAnswered && verdict.text.trim().toLowerCase().startsWith('no')) return { decision: 'block', reason: 'Finish the reply.' }
|
|
31
|
-
}, { timeoutMs: 8000 })
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
`HookInput<H>`, exported from `mod.js`, is the input a listener on `H` gets, for a listener that lives in a function of its own:
|
|
35
|
-
|
|
36
|
-
```ts
|
|
37
|
-
import { process, SessionStart, type HookAnswer, type HookInput } from '../node_modules/@cmodjs/core/mod.js'
|
|
38
|
-
|
|
39
|
-
export async function addBranch(input: HookInput<typeof SessionStart>): Promise<HookAnswer | undefined> {
|
|
40
|
-
const { stdout } = await process.run(['git', 'branch', '--show-current'], { cwd: input.cwd })
|
|
41
|
-
return { hookSpecificOutput: { hookEventName: SessionStart, additionalContext: `The git branch is ${stdout.trim()}.` } }
|
|
42
|
-
}
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
A `PreToolUse` hook runs on Claude Code's `tool.call`, before Claude Code checks the call's permission. It rewrites the call's input, adds context to it, or denies it, and Claude Code's permission check sees the rewritten input. A `permissionDecision` of `'allow'` or `'ask'` decides the call's permission, so it takes effect in Claude Code's permission check, and only when the mod's `hooks/register.ts` calls `registerPermissionCheck(addHook)` after `registerMod` ([mod.md](mod.md)):
|
|
46
|
-
|
|
47
|
-
- `'allow'` runs the call without asking the person, and `'ask'` asks them, with `permissionDecisionReason` as the question's reason.
|
|
48
|
-
- A deny from a settings rule or another hook still denies the call, and the mod's own `permissions` job can still ask or deny.
|
|
49
|
-
- Without `registerPermissionCheck`, an `'allow'` or `'ask'` denies the call with `<mod>: the PreToolUse hook answered permissionDecision "allow", so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.`
|
|
50
|
-
|
|
51
|
-
A hook on `PermissionRequest` takes part in that check too, so a mod with one does not start without `registerPermissionCheck`.
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
import { defineMod, PreToolUse } from '../node_modules/@cmodjs/core/mod.js'
|
|
55
|
-
|
|
56
|
-
export const safeDelete = defineMod({
|
|
57
|
-
name: 'safe-delete',
|
|
58
|
-
setup(mod) {
|
|
59
|
-
mod.on(PreToolUse, (input) => {
|
|
60
|
-
const command = input.tool_input['command']
|
|
61
|
-
if (input.tool_name !== 'Bash' || typeof command !== 'string' || !command.startsWith('rm ')) return
|
|
62
|
-
return { hookSpecificOutput: { hookEventName: PreToolUse, updatedInput: { ...input.tool_input, command: command.replace(/^rm /, 'trash ') } } }
|
|
63
|
-
})
|
|
64
|
-
},
|
|
65
|
-
})
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
## Every hook
|
|
69
|
-
|
|
70
|
-
`Hook` names the 33 hooks a mod listens to, every hook Claude Code has. The input of each is the input a `settings.json` hook reads, typed as `ClassicHookInputs['<hook>']` in the `claude-code` module, with the changes this table names.
|
|
71
|
-
|
|
72
|
-
| Hook | Input changes |
|
|
73
|
-
| --- | --- |
|
|
74
|
-
| `Setup` | none |
|
|
75
|
-
| `SessionStart` | none |
|
|
76
|
-
| `SessionEnd` | none |
|
|
77
|
-
| `UserPromptSubmit` | none |
|
|
78
|
-
| `UserPromptExpansion` | none |
|
|
79
|
-
| `InstructionsLoaded` | none |
|
|
80
|
-
| `ConfigChange` | none |
|
|
81
|
-
| `DirectoryAdded` | none |
|
|
82
|
-
| `PreModelSwitch` | none |
|
|
83
|
-
| `PostModelSwitch` | none |
|
|
84
|
-
| `PreToolUse` | Claude Mod Manager (cmod) builds the input: `session_id`, `cwd`, `hook_event_name`, `tool_name`, `tool_input`, `tool_use_id`, `agent_id` inside a subagent, `agent_type` inside a subagent or when the session started as an agent, and `files`. It has no `transcript_path` and no `permission_mode`. |
|
|
85
|
-
| `PermissionRequest` | none |
|
|
86
|
-
| `PermissionDenied` | none |
|
|
87
|
-
| `PostToolUse` | adds `files` |
|
|
88
|
-
| `PostToolUseFailure` | adds `files` |
|
|
89
|
-
| `PostToolBatch` | none |
|
|
90
|
-
| `MessageDisplay` | none |
|
|
91
|
-
| `SubagentStart` | none |
|
|
92
|
-
| `SubagentStop` | none |
|
|
93
|
-
| `TodoCreated` | none |
|
|
94
|
-
| `TodoCompleted` | none |
|
|
95
|
-
| `TeammateIdle` | none |
|
|
96
|
-
| `Elicitation` | none |
|
|
97
|
-
| `ElicitationResult` | none |
|
|
98
|
-
| `Notification` | none |
|
|
99
|
-
| `PreCompact` | none |
|
|
100
|
-
| `PostCompact` | none |
|
|
101
|
-
| `Stop` | none |
|
|
102
|
-
| `StopFailure` | none |
|
|
103
|
-
| `CwdChanged` | no `transcript_path` |
|
|
104
|
-
| `FileChanged` | none |
|
|
105
|
-
| `WorktreeCreate` | none |
|
|
106
|
-
| `WorktreeRemove` | none |
|
|
107
|
-
|
|
108
|
-
`TodoCreated` and `TodoCompleted` are Claude Code's `TaskCreated` and `TaskCompleted` hooks, under names that say what they hear: an item added to or checked off Claude Code's todo checklist, which its `TaskCreate`, `TaskUpdate`, and `TaskList` tools keep. A cmod `Task` is work handed to another session ([session.md](session.md#turn-context-goal-and-task)), and these hooks never hear one.
|
|
109
|
-
|
|
110
|
-
cmod raises `CwdChanged` itself, never Claude Code. It raises it when the working folder changes: after `/cd`, after a Bash or PowerShell call, and at a prompt. `sessions.current.cwd` and `sessions.current.project` already hold the new folder when the listener runs.
|
|
111
|
-
|
|
112
|
-
Claude Code raises `FileChanged` when a file it watches changes, with the file's `file_path` and `event`: `'change'`, `'add'`, or `'unlink'`. It watches the paths a `SessionStart` hook answers in `watchPaths`:
|
|
113
|
-
|
|
114
|
-
```ts
|
|
115
|
-
mod.on(SessionStart, () => ({ hookSpecificOutput: { hookEventName: SessionStart, watchPaths: [`${mod.dataFolder}/history`] } }))
|
|
116
|
-
mod.on(FileChanged, ({ file_path, event }) => mod.ui.toast(`${file_path}: ${event}`))
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
## files: what a tool call reads and changes
|
|
120
|
-
|
|
121
|
-
The input of `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` has `files: { read: string[]; changed: string[] }`, absolute paths that cmod works out from the call:
|
|
122
|
-
|
|
123
|
-
- `Read` reads its `file_path`. `Edit`, `Write`, and `NotebookEdit` change their path.
|
|
124
|
-
- `Grep` counts its `path`, or the working folder when it names none, and `Glob` counts its `pattern` joined to its `path`. Since `read` lists only files, a `Grep` of a folder and every `Glob` usually leave `read` empty.
|
|
125
|
-
- A `Bash` command reads and changes the files its commands name, such as `cat a.ts > b.ts`. A path that holds a shell expansion, such as `$HOME`, is left out.
|
|
126
|
-
- `read` lists only paths that are files now. `changed` lists every path the call writes, whether it exists or not.
|
|
127
|
-
- A path cmod cannot check, such as one it has no permission to read, is left out of `read`, and cmod writes the path and the reason to Claude Code's debug log.
|
|
128
|
-
- When cmod cannot work the files out, both lists are empty, and the reason goes to Claude Code's debug log.
|
|
129
|
-
|
|
130
|
-
## shell.js and path.js: read a command line
|
|
131
|
-
|
|
132
|
-
`shell.js` reads a Bash line the way cmod does for `files`, and `path.js` is `node:path`'s POSIX functions, which a mod cannot import from Node:
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
parseShell(line: string): ParsedShell
|
|
136
|
-
|
|
137
|
-
type ParsedShell = { commands: ShellCommand[]; reads: string[]; writes: string[]; fetches: string[]; isFullyParsed: boolean }
|
|
138
|
-
type ShellCommand = { argv: [string, ...string[]]; folder: string; input?: number }
|
|
139
|
-
|
|
140
|
-
resolve(...paths): string join(...paths): string dirname(path): string basename(path, suffix?): string
|
|
141
|
-
extname(path): string relative(from, to): string isAbsolute(path): boolean normalize(path): string
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
- `commands` lists every command the line runs, those inside `sudo`, `bash -c`, `$(…)`, and heredocs included, with flags such as `-rf` split into `-r` and `-f`.
|
|
145
|
-
- `folder` is where a command runs, as the line wrote it: `''` for the folder the line starts in, `src` after `cd src`, `/tmp` after `cd /tmp`, and `~/p` after `cd ~/p`. `resolve(input.cwd, folder)` makes it absolute.
|
|
146
|
-
- `input` is the index in `commands` of the command whose output this one reads through `|` or `|&`, and is absent for a command that reads no pipe. `trace read a.ts | head -5` gives `head` the `input` `0`. A wrapper such as `timeout 5 trace read a.ts` writes the output of the command it runs, so `input` names `trace`, not `timeout`.
|
|
147
|
-
- `reads`, `writes`, and `fetches` are the paths and URLs the commands name, as written. A path that holds a shell expansion makes `isFullyParsed` false, and so does a line the parser cannot read.
|
|
148
|
-
- `resolve` has no working folder to start from in Claude Code's hooks, so pass an absolute folder first, such as `resolve(sessions.current.cwd, path)`.
|
|
149
|
-
|
|
150
|
-
```ts
|
|
151
|
-
import { basename, resolve } from '../node_modules/@cmodjs/core/path.js'
|
|
152
|
-
import { parseShell } from '../node_modules/@cmodjs/core/shell.js'
|
|
153
|
-
import { PreToolUse } from '../node_modules/@cmodjs/core/mod.js'
|
|
154
|
-
|
|
155
|
-
mod.on(PreToolUse, (input) => {
|
|
156
|
-
if (input.tool_name !== 'Bash') return
|
|
157
|
-
for (const { argv, folder } of parseShell(String(input.tool_input['command'])).commands) {
|
|
158
|
-
if (basename(argv[0]) === 'trace') mod.ui.toast(`trace runs in ${resolve(input.cwd, folder)}`)
|
|
159
|
-
}
|
|
160
|
-
})
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
## HookAnswer: what a hook can answer
|
|
164
|
-
|
|
165
|
-
Return nothing to leave the event as it is. Return a `HookAnswer` to change it. The fields are the ones a `settings.json` hook writes as JSON:
|
|
166
|
-
|
|
167
|
-
```ts
|
|
168
|
-
type HookAnswer = {
|
|
169
|
-
continue?: boolean
|
|
170
|
-
stopReason?: string
|
|
171
|
-
suppressOutput?: boolean
|
|
172
|
-
systemMessage?: string
|
|
173
|
-
decision?: 'block'
|
|
174
|
-
reason?: string
|
|
175
|
-
hookSpecificOutput?: {
|
|
176
|
-
hookEventName?: Hook
|
|
177
|
-
additionalContext?: string
|
|
178
|
-
permissionDecision?: 'allow' | 'deny' | 'ask'
|
|
179
|
-
permissionDecisionReason?: string
|
|
180
|
-
updatedInput?: Record<string, unknown>
|
|
181
|
-
sessionTitle?: string
|
|
182
|
-
suppressOriginalPrompt?: boolean
|
|
183
|
-
initialUserMessage?: string
|
|
184
|
-
watchPaths?: string[]
|
|
185
|
-
reloadSkills?: boolean
|
|
186
|
-
decision?: PermissionRequestDecision
|
|
187
|
-
updatedToolOutput?: unknown
|
|
188
|
-
updatedMCPToolOutput?: unknown
|
|
189
|
-
retry?: boolean
|
|
190
|
-
displayContent?: string
|
|
191
|
-
worktreePath?: string
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
- `continue: false` stops the session after the event, and `stopReason` is the text Claude Code shows. `PreToolUse` takes neither.
|
|
197
|
-
- `decision: 'block'` with a `reason` blocks the event. On `PreToolUse` it denies the call with the reason. On `Stop` it keeps Claude going with the reason.
|
|
198
|
-
- `systemMessage` shows the text to the person as a log line.
|
|
199
|
-
- cmod ignores `suppressOutput`.
|
|
200
|
-
- `hookSpecificOutput.hookEventName` is optional. When set, it must be the event's own name.
|
|
201
|
-
|
|
202
|
-
Each event reads only some `hookSpecificOutput` fields:
|
|
203
|
-
|
|
204
|
-
| Event | `hookSpecificOutput` fields it reads |
|
|
205
|
-
| --- | --- |
|
|
206
|
-
| `SessionStart` | `additionalContext`, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills` |
|
|
207
|
-
| `UserPromptSubmit` | `additionalContext`, `sessionTitle`, `suppressOriginalPrompt` |
|
|
208
|
-
| `UserPromptExpansion` | `additionalContext`, `suppressOriginalPrompt` |
|
|
209
|
-
| `PreModelSwitch` | `permissionDecision`, `permissionDecisionReason` |
|
|
210
|
-
| `PreToolUse` | `additionalContext`, `permissionDecision` (`'allow'` and `'ask'` need `registerPermissionCheck`), `permissionDecisionReason`, `updatedInput` |
|
|
211
|
-
| `PermissionRequest` | `decision` |
|
|
212
|
-
| `PermissionDenied` | `retry` |
|
|
213
|
-
| `PostToolUse` | `additionalContext`, `updatedToolOutput`, `updatedMCPToolOutput` |
|
|
214
|
-
| `MessageDisplay` | `displayContent`: the text the person sees in place of the message |
|
|
215
|
-
| `WorktreeCreate` | `worktreePath`: the worktree the hook made, in place of Claude Code's own |
|
|
216
|
-
| `Setup`, `PostModelSwitch`, `PostToolUseFailure`, `PostToolBatch`, `SubagentStart`, `SubagentStop`, `Stop` | `additionalContext` |
|
|
217
|
-
| `SessionEnd`, `InstructionsLoaded`, `ConfigChange`, `DirectoryAdded`, `TodoCreated`, `TodoCompleted`, `TeammateIdle`, `Elicitation`, `ElicitationResult`, `Notification`, `PreCompact`, `PostCompact`, `StopFailure`, `CwdChanged`, `FileChanged`, `WorktreeRemove` | none |
|
|
218
|
-
|
|
219
|
-
A field the event does not read is an error: `it answered hookSpecificOutput.<field>, which <event> does not read. Remove it from the answer.` A flag set to `false`, such as `retry: false`, counts as unset.
|
|
220
|
-
|
|
221
|
-
## How answers combine
|
|
222
|
-
|
|
223
|
-
Every mod's hooks and Claude Code's own `settings.json` hooks answer the same event.
|
|
224
|
-
|
|
225
|
-
- `additionalContext` from every hook reaches Claude, each in turn.
|
|
226
|
-
- `PreToolUse` hooks run one inside the next, in the order mods and `mod.on` added them. A deny stops the call there, so no hook after it runs. `updatedInput` replaces the tool's input for every hook after it and for Claude Code. `additionalContext` reaches Claude with the call's result, and not at all when Claude Code refuses the call.
|
|
227
|
-
- For every other field, the mod's answer replaces the answer of the hooks beneath it.
|
|
228
|
-
|
|
229
|
-
```ts
|
|
230
|
-
import { defineMod, Stop } from '../node_modules/@cmodjs/core/mod.js'
|
|
231
|
-
|
|
232
|
-
export const keepGoing = defineMod({
|
|
233
|
-
name: 'keep-going',
|
|
234
|
-
state: { session: { hasCheckedTests: false } },
|
|
235
|
-
setup(mod) {
|
|
236
|
-
mod.on(Stop, () => {
|
|
237
|
-
if (mod.state.session.hasCheckedTests) return
|
|
238
|
-
mod.state.session.hasCheckedTests = true
|
|
239
|
-
return { decision: 'block', reason: 'Run the tests before you stop.' }
|
|
240
|
-
})
|
|
241
|
-
},
|
|
242
|
-
})
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
## When a hook fails
|
|
246
|
-
|
|
247
|
-
A hook that throws, rejects, or answers a field its event does not read fails:
|
|
248
|
-
|
|
249
|
-
- On `PreToolUse` the call is denied with `<mod>: the PreToolUse hook failed: <error>`.
|
|
250
|
-
- On `PermissionRequest` the request is denied with `<mod>: the PermissionRequest hook failed: <error>`.
|
|
251
|
-
- On every other hook cmod shows the error toast `The <hook> hook failed` with the error under it, and the hook goes on as if the listener answered nothing.
|
|
252
|
-
|
|
253
|
-
## The first SessionStart
|
|
254
|
-
|
|
255
|
-
A mod that is still installing when the session starts misses Claude Code's `SessionStart`. cmod holds `SessionStart` up to 9 seconds for the mod to start. When the mod starts later, cmod runs its `SessionStart` hooks then, and Claude Code no longer reads their answer.
|
|
256
|
-
|
|
257
|
-
## Test a hook
|
|
258
|
-
|
|
259
|
-
`tested.fire(event, input)` runs the mod's hooks on an event and returns the combined answer. It fills `session_id`, `transcript_path`, `cwd`, `hook_event_name`, and `tool_use_id`, so a test gives only the event's own fields. [testing.md](testing.md) covers it.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"document.d.ts","sourceRoot":"","sources":["../../../../src/session/context/turn/message/document.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAE7C,eAAO,MAAM,QAAQ,aAAa,CAAA;AAElC,MAAM,MAAM,QAAQ,GAAG,SAAS,CAAC,OAAO,QAAQ,CAAC,GAAG;IAClD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB,CAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const Document = 'document';
|