@cmodjs/core 0.1.5 → 0.1.7
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 -3
- package/docs/hooks.md +2 -0
- package/docs/index.md +4 -4
- package/docs/jobs.md +23 -22
- package/docs/mod.md +24 -9
- package/docs/testing.md +1 -1
- package/jobs/check.d.ts +2 -2
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +5 -5
- package/jobs/context.d.ts +14 -0
- package/jobs/context.d.ts.map +1 -0
- package/jobs/{part-context.js → context.js} +6 -6
- package/jobs/permissions.d.ts +2 -2
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +7 -7
- package/jobs/program.d.ts +2 -2
- package/jobs/program.d.ts.map +1 -1
- package/jobs/prompt.d.ts +2 -2
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +17 -17
- package/jobs/slash-command.d.ts +2 -2
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +5 -5
- package/jobs/status-line.d.ts +2 -2
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +5 -5
- package/jobs/tool.d.ts +2 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +5 -5
- package/mod.d.ts +3 -3
- package/mod.d.ts.map +1 -1
- package/package.json +2 -2
- package/register.d.ts +5 -0
- package/register.d.ts.map +1 -0
- package/{connect.js → register.js} +42 -39
- package/runtime/hooks.d.ts +1 -0
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +1 -0
- package/runtime/lifecycle.d.ts +1 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +16 -13
- package/runtime/router.d.ts +1 -0
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +1 -0
- package/runtime/tool-calls.d.ts +3 -2
- package/runtime/tool-calls.d.ts.map +1 -1
- package/runtime/tool-calls.js +11 -14
- package/src/jobs/check.ts +7 -7
- package/src/jobs/{part-context.ts → context.ts} +10 -10
- package/src/jobs/permissions.ts +9 -9
- package/src/jobs/program.ts +2 -2
- package/src/jobs/prompt.ts +19 -19
- package/src/jobs/slash-command.ts +7 -7
- package/src/jobs/status-line.ts +7 -7
- package/src/jobs/tool.ts +7 -7
- package/src/mod.ts +3 -3
- package/src/{connect.ts → register.ts} +44 -40
- package/src/runtime/hooks.ts +2 -0
- package/src/runtime/lifecycle.ts +18 -14
- package/src/runtime/router.ts +2 -0
- package/src/runtime/tool-calls.ts +12 -14
- package/vendor.js +1 -3
- package/connect.d.ts +0 -4
- package/connect.d.ts.map +0 -1
- package/jobs/part-context.d.ts +0 -14
- package/jobs/part-context.d.ts.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Args, Frozen, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
|
|
2
|
-
import type {
|
|
2
|
+
import type { JobContext, Mod } from '../mod.js'
|
|
3
3
|
import type { Claude } from '../runtime/claude.js'
|
|
4
4
|
import { beforeDeadline, type Deadline } from '../runtime/deadline.js'
|
|
5
5
|
import { dependencyCalls } from '../runtime/dependencies.js'
|
|
@@ -14,7 +14,7 @@ type Session = Omit<Workspace, 'projectRoot' | 'cwd'>
|
|
|
14
14
|
|
|
15
15
|
type Stream = HookStream<ProcessSpawnChunk, ProcessSpawnResult>
|
|
16
16
|
|
|
17
|
-
export function workspaceReader({ claude }:
|
|
17
|
+
export function workspaceReader({ claude }: JobContext): () => Promise<Workspace> {
|
|
18
18
|
let session: Promise<Session> | undefined
|
|
19
19
|
return async () => {
|
|
20
20
|
session ??= readSession(claude)
|
|
@@ -35,13 +35,13 @@ async function readSession(claude: Claude): Promise<Session> {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export function afterCall<State extends object>(
|
|
38
|
-
|
|
38
|
+
job: JobContext<State>,
|
|
39
39
|
contextAfter: (use: ToolUse, workspace: Workspace) => Promise<readonly string[]>,
|
|
40
40
|
failed: (error: unknown) => readonly string[],
|
|
41
41
|
): void {
|
|
42
|
-
const readWorkspace = workspaceReader(
|
|
43
|
-
|
|
44
|
-
const before = Promise.all([useOf(
|
|
42
|
+
const readWorkspace = workspaceReader(job)
|
|
43
|
+
job.on('tool.call', async (e, next) => {
|
|
44
|
+
const before = Promise.all([useOf(job, e), readWorkspace()])
|
|
45
45
|
await before.catch(() => undefined)
|
|
46
46
|
const result = await next(e)
|
|
47
47
|
if (result.deny !== undefined || result.isError === true) return result
|
|
@@ -50,15 +50,15 @@ export function afterCall<State extends object>(
|
|
|
50
50
|
})
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
async function useOf({ toolCalls }:
|
|
53
|
+
async function useOf({ toolCalls }: JobContext, e: Frozen<Args<'tool.call'>>): Promise<ToolUse> {
|
|
54
54
|
return { tool: e.tool, input: toolInputOf(e), ...(await toolCalls.agentOf(e.tool_use_id)) }
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function modOf<State extends object>(
|
|
58
|
-
return modWithin(
|
|
57
|
+
export function modOf<State extends object>(job: JobContext<State>, { call, folder }: { readonly call?: ToolCall; readonly folder: string }, workspace: Workspace, deadline: Deadline): Mod<State> {
|
|
58
|
+
return modWithin(job, deadline, async () => workspace.scope?.workTreeOf(call !== undefined && 'path' in call ? call.path : folder))
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export function modWithin<State extends object>({ mod, claude }:
|
|
61
|
+
export function modWithin<State extends object>({ mod, claude }: JobContext<State>, deadline: Deadline, workTree: () => Promise<string | undefined> = async () => undefined): Mod<State> {
|
|
62
62
|
const within = <Value>(call: string, task: Promise<Value>) => beforeDeadline(claude, deadline, call, task)
|
|
63
63
|
const folderOf = async (cwd: string | undefined) => {
|
|
64
64
|
const folder = cwd ?? (await workTree())
|
package/src/jobs/permissions.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Args, Frozen } from 'claude-code'
|
|
2
|
-
import type {
|
|
2
|
+
import type { Job, Mod } from '../mod.js'
|
|
3
3
|
import type { Deadline } from '../runtime/deadline.js'
|
|
4
4
|
import type { ToolUse } from '../utils/call-effects.js'
|
|
5
5
|
import { decidePermission, stricterVerdict, type PermissionRules as RulesFor, type Rule as RuleFor, type Verdict } from './permissions/decide-permission.js'
|
|
6
6
|
import { targetOf } from './permissions/match-target.js'
|
|
7
7
|
import { messageOf } from '../utils/text.js'
|
|
8
|
-
import { modOf, workspaceReader } from './
|
|
8
|
+
import { modOf, workspaceReader } from './context.js'
|
|
9
9
|
|
|
10
10
|
export type { CommandCall, FetchCall, FileCall, ToolCall } from '../utils/call-effects.js'
|
|
11
11
|
export type { Target } from './permissions/match-target.js'
|
|
@@ -14,28 +14,28 @@ export type PermissionRules<State extends object = Record<never, never>> = Rules
|
|
|
14
14
|
|
|
15
15
|
const deadline: Deadline = { ms: 2000, job: 'permissions' }
|
|
16
16
|
|
|
17
|
-
export function permissions<State extends object = Record<never, never>>(rules: PermissionRules<State>):
|
|
17
|
+
export function permissions<State extends object = Record<never, never>>(rules: PermissionRules<State>): Job<void, State> {
|
|
18
18
|
const count = (rules.deny?.length ?? 0) + (rules.ask?.length ?? 0)
|
|
19
19
|
if (count === 0) throw new Error('permissions: give it a deny or an ask rule, or remove it from setup.')
|
|
20
20
|
for (const rule of [...(rules.deny ?? []), ...(rules.ask ?? [])]) targetOf(rule)
|
|
21
21
|
|
|
22
|
-
return (
|
|
23
|
-
const readWorkspace = workspaceReader(
|
|
22
|
+
return (job) => {
|
|
23
|
+
const readWorkspace = workspaceReader(job)
|
|
24
24
|
|
|
25
25
|
const verdictOf = async (e: Frozen<Args<'tool.check'>>): Promise<Verdict | undefined> => {
|
|
26
26
|
try {
|
|
27
|
-
const [agent, workspace] = await Promise.all([
|
|
27
|
+
const [agent, workspace] = await Promise.all([job.toolCalls.agentOf(e.tool_use_id), readWorkspace()])
|
|
28
28
|
const use: ToolUse = { tool: e.tool, input: e.input, ...agent }
|
|
29
|
-
return await decidePermission(rules, use, workspace, (call, folder) => modOf(
|
|
29
|
+
return await decidePermission(rules, use, workspace, (call, folder) => modOf(job, { call, folder }, workspace, deadline))
|
|
30
30
|
} catch (error) {
|
|
31
31
|
return { decision: 'deny', reason: `The permissions job could not read the session, so it denies the call: ${messageOf(error)}` }
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
job.on('tool.check', async (e, next) => {
|
|
36
36
|
const [below, ours] = await Promise.all([next(e), verdictOf(e)])
|
|
37
37
|
return ours === undefined ? below : stricterVerdict<Verdict>(below, ours)
|
|
38
38
|
})
|
|
39
|
-
|
|
39
|
+
job.announce(`${count} permission rule${count === 1 ? '' : 's'}`)
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/jobs/program.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Job } from '../mod.js'
|
|
2
2
|
import { messageOf } from '../utils/text.js'
|
|
3
3
|
|
|
4
4
|
export type Program = {
|
|
@@ -10,7 +10,7 @@ type Address = Awaited<ReturnType<Program['ready']>>
|
|
|
10
10
|
|
|
11
11
|
const backoffSeconds = [1, 2, 4, 8, 16] as const
|
|
12
12
|
|
|
13
|
-
export function program(options: { readonly command: readonly string[]; readonly environment?: Record<string, string> }):
|
|
13
|
+
export function program(options: { readonly command: readonly string[]; readonly environment?: Record<string, string> }): Job<Program> {
|
|
14
14
|
const { command, environment } = options
|
|
15
15
|
return ({ mod, claude, announce }) => {
|
|
16
16
|
const [executable] = command
|
package/src/jobs/prompt.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Job, JobContext, Mod } from '../mod.js'
|
|
2
2
|
import { longestMs, type Deadline } from '../runtime/deadline.js'
|
|
3
3
|
import { listed, messageOf } from '../utils/text.js'
|
|
4
4
|
import { callEffects, type ToolCall } from '../utils/call-effects.js'
|
|
5
5
|
import { matchTarget, targetOf, type Target } from './permissions/match-target.js'
|
|
6
|
-
import { afterCall, modOf, modWithin, targetWords } from './
|
|
6
|
+
import { afterCall, modOf, modWithin, targetWords } from './context.js'
|
|
7
7
|
|
|
8
8
|
type PromptInput = { userPrompt?: string; call?: ToolCall }
|
|
9
9
|
|
|
@@ -16,16 +16,16 @@ type PromptOptions<State extends object> = {
|
|
|
16
16
|
|
|
17
17
|
const deadline: Deadline = { ms: 5000, longestMs, job: 'prompt' }
|
|
18
18
|
|
|
19
|
-
export function prompt<State extends object = Record<never, never>>({ name, prompt: text, when, after }: PromptOptions<State>):
|
|
19
|
+
export function prompt<State extends object = Record<never, never>>({ name, prompt: text, when, after }: PromptOptions<State>): Job<void, State> {
|
|
20
20
|
if (name.trim() === '') throw new Error('prompt: give the block a name. Claude reads the text under it.')
|
|
21
21
|
if (when !== undefined && after !== undefined) throw new Error(`prompt "${name}": use when or after, not both. Add a second prompt for the other trigger.`)
|
|
22
22
|
const targets = after === undefined ? [] : Array.isArray(after) ? after : [after]
|
|
23
23
|
for (const target of targets) targetOf(target)
|
|
24
24
|
|
|
25
|
-
return (
|
|
26
|
-
|
|
25
|
+
return (job) => {
|
|
26
|
+
job.reserveName('prompt', name, `prompt: ${job.mod.name} adds the name "${name}" two times. Give each prompt its own name.`)
|
|
27
27
|
|
|
28
|
-
const log = (reason: string) =>
|
|
28
|
+
const log = (reason: string) => job.claude.ui.log(`prompt "${name}" added nothing: ${reason}`, { to: 'debug' })
|
|
29
29
|
const textFor = async (input: PromptInput, mod: Mod<State>) => {
|
|
30
30
|
try {
|
|
31
31
|
const value = typeof text === 'string' ? text : await text(input, mod)
|
|
@@ -42,20 +42,20 @@ export function prompt<State extends object = Record<never, never>>({ name, prom
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
if (when !== undefined) {
|
|
45
|
-
addAfterUserPrompts(
|
|
46
|
-
|
|
45
|
+
addAfterUserPrompts(job, when, (userPrompt) => headedText({ userPrompt }, modWithin(job, deadline)), log)
|
|
46
|
+
job.announce(`the ${name} prompt after matching user prompts`)
|
|
47
47
|
} else if (targets.length > 0) {
|
|
48
|
-
addAfterCalls(
|
|
49
|
-
|
|
48
|
+
addAfterCalls(job, targets, headedText, log)
|
|
49
|
+
job.announce(`the ${name} prompt after ${listed(targets.map(targetWords))}`)
|
|
50
50
|
} else {
|
|
51
|
-
addOncePerConversation(
|
|
52
|
-
|
|
51
|
+
addOncePerConversation(job, name, () => textFor({}, modWithin(job, deadline)), log)
|
|
52
|
+
job.announce(`the ${name} prompt`)
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function addOncePerConversation(
|
|
58
|
-
|
|
57
|
+
function addOncePerConversation(job: JobContext, name: string, textFor: () => Promise<string | undefined>, log: (reason: string) => void): void {
|
|
58
|
+
job.on('prompt.context', async (e, next) => {
|
|
59
59
|
const below = await next(e)
|
|
60
60
|
if (below.blocks.some((block) => block.name === name)) {
|
|
61
61
|
log(`a block named "${name}" is already in the context`)
|
|
@@ -66,8 +66,8 @@ function addOncePerConversation(part: PartContext, name: string, textFor: () =>
|
|
|
66
66
|
})
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function addAfterUserPrompts(
|
|
70
|
-
|
|
69
|
+
function addAfterUserPrompts(job: JobContext, when: RegExp | ((userPrompt: string) => boolean), textFor: (userPrompt: string) => Promise<string | undefined>, log: (reason: string) => void): void {
|
|
70
|
+
job.on('prompt.submit', async (e, next) => {
|
|
71
71
|
let value: string | undefined
|
|
72
72
|
try {
|
|
73
73
|
if (typeof when === 'function' ? when(e.text) : e.text.search(when) >= 0) value = await textFor(e.text)
|
|
@@ -79,19 +79,19 @@ function addAfterUserPrompts(part: PartContext, when: RegExp | ((userPrompt: str
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
function addAfterCalls<State extends object>(
|
|
82
|
-
|
|
82
|
+
job: JobContext<State>,
|
|
83
83
|
targets: Target[],
|
|
84
84
|
textFor: (input: PromptInput, mod: Mod<State>) => Promise<string | undefined>,
|
|
85
85
|
log: (reason: string) => void,
|
|
86
86
|
): void {
|
|
87
87
|
afterCall(
|
|
88
|
-
|
|
88
|
+
job,
|
|
89
89
|
async (use, workspace) => {
|
|
90
90
|
const effects = callEffects(use, workspace)
|
|
91
91
|
for (const target of targets) {
|
|
92
92
|
const [matched] = await matchTarget(target, use, effects, workspace)
|
|
93
93
|
if (matched === undefined) continue
|
|
94
|
-
const value = await textFor({ call: matched.call }, modOf(
|
|
94
|
+
const value = await textFor({ call: matched.call }, modOf(job, matched, workspace, deadline))
|
|
95
95
|
return value === undefined ? [] : [value]
|
|
96
96
|
}
|
|
97
97
|
return []
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { CommandRunResult } from 'claude-code'
|
|
2
|
-
import type {
|
|
2
|
+
import type { Job, Mod } from '../mod.js'
|
|
3
3
|
import { parse } from '../vendor.js'
|
|
4
4
|
import { longestMs, type Deadline } from '../runtime/deadline.js'
|
|
5
5
|
import { messageOf } from '../utils/text.js'
|
|
6
|
-
import { modWithin } from './
|
|
6
|
+
import { modWithin } from './context.js'
|
|
7
7
|
|
|
8
8
|
export type Reply = string | { text?: string; context?: string } | undefined
|
|
9
9
|
|
|
@@ -17,10 +17,10 @@ export function slashCommand<State extends object = Record<never, never>>(option
|
|
|
17
17
|
readonly argumentHint?: string
|
|
18
18
|
readonly immediate?: true
|
|
19
19
|
readonly reply: (input: { args: string; positionals: string[] }, mod: Mod<State>) => Reply | void | Promise<Reply | void>
|
|
20
|
-
}):
|
|
20
|
+
}): Job<void, State> {
|
|
21
21
|
const { name, description, argumentHint, immediate, reply } = options
|
|
22
|
-
return (
|
|
23
|
-
const { mod, claude,
|
|
22
|
+
return (job) => {
|
|
23
|
+
const { mod, claude, announce, reserveName } = job
|
|
24
24
|
if (!commandName.test(name)) throw new Error(`${mod.name}: "${name}" is not a slash command name. Use 1 to 64 letters, digits, "_", or "-", without the slash.`)
|
|
25
25
|
reserveName('slashCommand', name, `${mod.name}: the slash command /${name} is already added. Give each slashCommand its own name.`)
|
|
26
26
|
|
|
@@ -35,8 +35,8 @@ export function slashCommand<State extends object = Record<never, never>>(option
|
|
|
35
35
|
)
|
|
36
36
|
announce(`/${name}`)
|
|
37
37
|
|
|
38
|
-
const replyMod = modWithin(
|
|
39
|
-
on('command.run', async (e, next) => {
|
|
38
|
+
const replyMod = modWithin(job, deadline)
|
|
39
|
+
job.on('command.run', async (e, next) => {
|
|
40
40
|
if (e.command !== registered) return next(e)
|
|
41
41
|
try {
|
|
42
42
|
return answerOf(await reply({ args: e.args, positionals: splitWords(e.args) }, replyMod))
|
package/src/jobs/status-line.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Job, Mod } from '../mod.js'
|
|
2
2
|
import type { Deadline } from '../runtime/deadline.js'
|
|
3
3
|
import { messageOf } from '../utils/text.js'
|
|
4
|
-
import { modWithin } from './
|
|
4
|
+
import { modWithin } from './context.js'
|
|
5
5
|
|
|
6
6
|
export type Usage = {
|
|
7
7
|
model: string
|
|
@@ -14,14 +14,14 @@ const deadline: Deadline = { ms: 2000, job: 'statusLine' }
|
|
|
14
14
|
export function statusLine<State extends object = Record<never, never>>(options: {
|
|
15
15
|
readonly text: (usage: Usage, mod: Mod<State>) => string | undefined | Promise<string | undefined>
|
|
16
16
|
readonly interval?: number
|
|
17
|
-
}):
|
|
17
|
+
}): Job<void, State> {
|
|
18
18
|
const { text, interval = 10000 } = options
|
|
19
|
-
return (
|
|
20
|
-
const { mod, claude,
|
|
19
|
+
return (job) => {
|
|
20
|
+
const { mod, claude, announce, reserveName } = job
|
|
21
21
|
if (!(interval >= 1)) throw new Error(`${mod.name}: the status line interval is ${interval}. Give it in milliseconds, 1 or more.`)
|
|
22
22
|
reserveName('statusLine', '', `${mod.name}: a status line is already added. A mod has one status line: join the texts in one statusLine.`)
|
|
23
23
|
|
|
24
|
-
const textMod = modWithin(
|
|
24
|
+
const textMod = modWithin(job, deadline)
|
|
25
25
|
let shown: string | undefined
|
|
26
26
|
let queue = Promise.resolve()
|
|
27
27
|
const update = async () => {
|
|
@@ -48,7 +48,7 @@ export function statusLine<State extends object = Record<never, never>>(options:
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
announce('a status line')
|
|
51
|
-
on('session.measure', (e, next) => {
|
|
51
|
+
job.on('session.measure', (e, next) => {
|
|
52
52
|
refresh()
|
|
53
53
|
return next(e)
|
|
54
54
|
})
|
package/src/jobs/tool.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { FromSchema, JSONSchema } from 'json-schema-to-ts'
|
|
2
|
-
import type {
|
|
2
|
+
import type { Job, Mod } from '../mod.js'
|
|
3
3
|
import { Validator } from '../vendor.js'
|
|
4
4
|
import { toolDeadline } from '../runtime/deadline.js'
|
|
5
5
|
import { reservedKeys, toolInputOf } from '../runtime/tool-calls.js'
|
|
6
6
|
import { messageOf } from '../utils/text.js'
|
|
7
|
-
import { modWithin } from './
|
|
7
|
+
import { modWithin } from './context.js'
|
|
8
8
|
|
|
9
9
|
const toolName = /^[A-Za-z0-9_-]{1,64}$/
|
|
10
10
|
|
|
@@ -13,10 +13,10 @@ export function tool<const Schema extends object = { type: 'object' }, State ext
|
|
|
13
13
|
readonly description: string
|
|
14
14
|
readonly inputSchema?: Schema
|
|
15
15
|
readonly execute: (input: Schema extends JSONSchema ? FromSchema<Schema> : never, mod: Mod<State>) => unknown
|
|
16
|
-
}):
|
|
16
|
+
}): Job<{ readonly name: string }, State> {
|
|
17
17
|
const { name, description, inputSchema, execute } = options
|
|
18
|
-
return (
|
|
19
|
-
const { mod, claude,
|
|
18
|
+
return (job) => {
|
|
19
|
+
const { mod, claude, announce, reserveName } = job
|
|
20
20
|
if (!toolName.test(name)) throw new Error(`${mod.name}: "${name}" is not a tool name. Use 1 to 64 letters, digits, "_", or "-".`)
|
|
21
21
|
const properties = (inputSchema as { properties?: object } | undefined)?.properties ?? {}
|
|
22
22
|
const reserved = reservedKeys.filter((key) => Object.hasOwn(properties, key))
|
|
@@ -35,8 +35,8 @@ export function tool<const Schema extends object = { type: 'object' }, State ext
|
|
|
35
35
|
)
|
|
36
36
|
announce(`the ${name} tool`)
|
|
37
37
|
|
|
38
|
-
const executeMod = modWithin(
|
|
39
|
-
on('tool.call', async (e, next) => {
|
|
38
|
+
const executeMod = modWithin(job, toolDeadline)
|
|
39
|
+
job.on('tool.call', async (e, next) => {
|
|
40
40
|
if (e.tool !== registered) return next(e)
|
|
41
41
|
const input = toolInputOf(e)
|
|
42
42
|
const { valid, errors } = validator.validate(input)
|
package/src/mod.ts
CHANGED
|
@@ -111,7 +111,7 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
111
111
|
readonly state: Readonly<State>
|
|
112
112
|
readonly dataFolder: string
|
|
113
113
|
on<E extends ModEvent>(event: E, hook: ModHook<E>): void
|
|
114
|
-
use<Handle>(
|
|
114
|
+
use<Handle>(job: Job<Handle, State>): Handle
|
|
115
115
|
readonly ui: {
|
|
116
116
|
pane(pane: Pane<State>): PaneHandle
|
|
117
117
|
render<S extends Slot>(slot: S, Component: (props: SlotProps<S>) => RenderElement): void
|
|
@@ -157,7 +157,7 @@ export type ModDefinition<State extends StateGroups = Record<never, never>, Name
|
|
|
157
157
|
setup(mod: Mod<State>): void | Promise<void>
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
export type
|
|
160
|
+
export type JobContext<State extends object = Record<never, never>> = {
|
|
161
161
|
readonly mod: Mod<State>
|
|
162
162
|
readonly claude: Claude
|
|
163
163
|
on<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): void
|
|
@@ -166,7 +166,7 @@ export type PartContext<State extends object = Record<never, never>> = {
|
|
|
166
166
|
readonly toolCalls: ToolCalls
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
export type
|
|
169
|
+
export type Job<Handle, State extends object = Record<never, never>> = (context: JobContext<State>) => Handle
|
|
170
170
|
|
|
171
171
|
export function defineMod<State extends StateGroups = Record<never, never>, Name extends string = string>(definition: ModDefinition<State, Name>): ModDefinition<State, Name> {
|
|
172
172
|
if (definition.name.trim() === '') throw new Error('defineMod: the mod needs a name, such as the name in .claude-plugin/plugin.json.')
|
|
@@ -6,17 +6,10 @@ import { createLifecycle, readPlugin, type Lifecycle } from './runtime/lifecycle
|
|
|
6
6
|
|
|
7
7
|
let lifecycle: Lifecycle<object>
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
await lifecycle.start(claudeOf($), readPlugin)
|
|
11
|
-
return next(e)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function routeToMod<N extends RoutedEvent>(_$: EngineInterface, e: Frozen<Args<N>>, next: Next<N>): Promise<EventResult<N>> {
|
|
15
|
-
return lifecycle.route(next.event, e, (passed: unknown) => next(passed as Args<N>) as Promise<EventResult<N>>)
|
|
16
|
-
}
|
|
9
|
+
let checksPermissions = false
|
|
17
10
|
|
|
18
|
-
function
|
|
19
|
-
|
|
11
|
+
async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.start'>>, passOn: Next<'session.start'>): Promise<EventResult<'session.start'>> {
|
|
12
|
+
const claude: Claude = {
|
|
20
13
|
plugin: { name: $.plugin.name, root: $.plugin.root },
|
|
21
14
|
ui: {
|
|
22
15
|
toast: (text, options) => $.ui.toast(text, options),
|
|
@@ -72,36 +65,47 @@ function claudeOf($: EngineInterface): Claude {
|
|
|
72
65
|
},
|
|
73
66
|
cmod: { call: (input) => $.cmod.call(input) },
|
|
74
67
|
}
|
|
68
|
+
await lifecycle.start(claude, readPlugin)
|
|
69
|
+
return passOn(eventInput)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function routeToMod<N extends RoutedEvent>(_$: EngineInterface, eventInput: Frozen<Args<N>>, passOn: Next<N>): Promise<EventResult<N>> {
|
|
73
|
+
return lifecycle.route(passOn.event, eventInput, (passed: unknown) => passOn(passed as Args<N>) as Promise<EventResult<N>>)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerMod<State extends object>(addHook: On, definition: ModDefinition<State>): void {
|
|
77
|
+
checksPermissions = false
|
|
78
|
+
lifecycle = createLifecycle(definition, () => checksPermissions)
|
|
79
|
+
addHook('session.start', startMod)
|
|
80
|
+
addHook('classic.SessionStart', routeToMod)
|
|
81
|
+
addHook('classic.SessionEnd', routeToMod)
|
|
82
|
+
addHook('classic.UserPromptSubmit', routeToMod)
|
|
83
|
+
addHook('classic.InstructionsLoaded', routeToMod)
|
|
84
|
+
addHook('classic.PermissionDenied', routeToMod)
|
|
85
|
+
addHook('classic.PostToolUse', routeToMod)
|
|
86
|
+
addHook('classic.PostToolUseFailure', routeToMod)
|
|
87
|
+
addHook('classic.PostToolBatch', routeToMod)
|
|
88
|
+
addHook('classic.SubagentStart', routeToMod)
|
|
89
|
+
addHook('classic.SubagentStop', routeToMod)
|
|
90
|
+
addHook('classic.Notification', routeToMod)
|
|
91
|
+
addHook('classic.PreCompact', routeToMod)
|
|
92
|
+
addHook('classic.Stop', routeToMod)
|
|
93
|
+
addHook('classic.StopFailure', routeToMod)
|
|
94
|
+
addHook('tool.call', routeToMod)
|
|
95
|
+
addHook('prompt.submit', routeToMod)
|
|
96
|
+
addHook('prompt.context', routeToMod)
|
|
97
|
+
addHook('command.run', routeToMod)
|
|
98
|
+
addHook('session.measure', routeToMod)
|
|
99
|
+
addHook('skill.prompt', routeToMod)
|
|
100
|
+
addHook('ui.render', routeToMod)
|
|
101
|
+
addHook('ui.press', routeToMod)
|
|
102
|
+
addHook('ui.close', routeToMod)
|
|
103
|
+
addHook('cmod.call', routeToMod)
|
|
75
104
|
}
|
|
76
105
|
|
|
77
|
-
export function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
on('classic.UserPromptSubmit', routeToMod)
|
|
83
|
-
on('classic.InstructionsLoaded', routeToMod)
|
|
84
|
-
on('classic.PreToolUse', routeToMod)
|
|
85
|
-
on('classic.PermissionRequest', routeToMod)
|
|
86
|
-
on('classic.PermissionDenied', routeToMod)
|
|
87
|
-
on('classic.PostToolUse', routeToMod)
|
|
88
|
-
on('classic.PostToolUseFailure', routeToMod)
|
|
89
|
-
on('classic.PostToolBatch', routeToMod)
|
|
90
|
-
on('classic.SubagentStart', routeToMod)
|
|
91
|
-
on('classic.SubagentStop', routeToMod)
|
|
92
|
-
on('classic.Notification', routeToMod)
|
|
93
|
-
on('classic.PreCompact', routeToMod)
|
|
94
|
-
on('classic.Stop', routeToMod)
|
|
95
|
-
on('classic.StopFailure', routeToMod)
|
|
96
|
-
on('tool.check', routeToMod)
|
|
97
|
-
on('tool.call', routeToMod)
|
|
98
|
-
on('prompt.submit', routeToMod)
|
|
99
|
-
on('prompt.context', routeToMod)
|
|
100
|
-
on('command.run', routeToMod)
|
|
101
|
-
on('session.measure', routeToMod)
|
|
102
|
-
on('skill.prompt', routeToMod)
|
|
103
|
-
on('ui.render', routeToMod)
|
|
104
|
-
on('ui.press', routeToMod)
|
|
105
|
-
on('ui.close', routeToMod)
|
|
106
|
-
on('cmod.call', routeToMod)
|
|
106
|
+
export function registerPermissionCheck(addHook: On): void {
|
|
107
|
+
checksPermissions = true
|
|
108
|
+
addHook('tool.check', routeToMod)
|
|
109
|
+
addHook('classic.PreToolUse', routeToMod)
|
|
110
|
+
addHook('classic.PermissionRequest', routeToMod)
|
|
107
111
|
}
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type RoutedEvent =
|
|
|
22
22
|
| 'ui.close'
|
|
23
23
|
| 'cmod.call'
|
|
24
24
|
|
|
25
|
+
export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PreToolUse', 'classic.PermissionRequest']
|
|
26
|
+
|
|
25
27
|
type ClassicFields = Record<string, unknown>
|
|
26
28
|
|
|
27
29
|
type PreToolUseInput = Parameters<ModHook<'PreToolUse'>>[0]
|
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { Args, EventResult, Frozen, HookBudget, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
|
|
2
|
-
import type { Mod, ModDefinition, ModEvent, ModHook
|
|
2
|
+
import type { Mod, ModDefinition, ModEvent, ModHook } from '../mod.js'
|
|
3
3
|
import { dataFolder, isAtLeast, parseEvent, readRecord, readSteps, scriptsSha256, storeFolder, type ReadFile, type RunnerEvent } from '../records.js'
|
|
4
4
|
import { relativePath } from '../utils/paths.js'
|
|
5
5
|
import { formatExit, listed, messageOf } from '../utils/text.js'
|
|
6
6
|
import type { Claude } from './claude.js'
|
|
7
7
|
import { beforeDeadline } from './deadline.js'
|
|
8
8
|
import { answerCall, dependencyCalls, notInstalled } from './dependencies.js'
|
|
9
|
-
import { classicHook, userSkillHook, type RoutedEvent } from './hooks.js'
|
|
9
|
+
import { classicHook, permissionEvents, userSkillHook, type RoutedEvent } from './hooks.js'
|
|
10
10
|
import { createRouter, type Router, type RouterNext } from './router.js'
|
|
11
11
|
import { createState } from './state.js'
|
|
12
12
|
import { toolCalls } from './tool-calls.js'
|
|
@@ -36,6 +36,7 @@ type ModRuntime = {
|
|
|
36
36
|
readonly router: Router
|
|
37
37
|
readonly progress: Progress
|
|
38
38
|
readonly dataFolder: string
|
|
39
|
+
readonly checksPermissions: () => boolean
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
type ActiveMod<State extends object> = {
|
|
@@ -120,7 +121,7 @@ async function cmodVersion(claude: Claude): Promise<string | undefined> {
|
|
|
120
121
|
return result.stdout.trim().split(/\s+/).at(-1)
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
export function createLifecycle<State extends object>(definition: ModDefinition<State
|
|
124
|
+
export function createLifecycle<State extends object>(definition: ModDefinition<State>, checksPermissions: () => boolean = () => true): Lifecycle<State> {
|
|
124
125
|
let router: Router = createRouter()
|
|
125
126
|
let phase: Phase = 'starting'
|
|
126
127
|
let runtime: Pick<ModRuntime, 'claude' | 'progress'> | undefined
|
|
@@ -136,7 +137,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
136
137
|
const startSettled = new Promise<void>((resolve) => (settleStart = resolve))
|
|
137
138
|
|
|
138
139
|
const claude = () => {
|
|
139
|
-
if (runtime === undefined) throw new Error(`${definition.name}: the lifecycle has not started.
|
|
140
|
+
if (runtime === undefined) throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod) starts it at session.start.`)
|
|
140
141
|
return runtime.claude
|
|
141
142
|
}
|
|
142
143
|
|
|
@@ -180,7 +181,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
180
181
|
const activate = async () => {
|
|
181
182
|
if (runtime === undefined || plugin === undefined) return
|
|
182
183
|
const activeRouter = createRouter()
|
|
183
|
-
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name) }).catch((error: unknown) => {
|
|
184
|
+
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), checksPermissions }).catch((error: unknown) => {
|
|
184
185
|
fail(messageOf(error), 'Fix it, then run /reload-plugins.')
|
|
185
186
|
throw error
|
|
186
187
|
})
|
|
@@ -355,7 +356,6 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
355
356
|
const announce = (feature: string) => {
|
|
356
357
|
added.push(feature)
|
|
357
358
|
}
|
|
358
|
-
const on: PartContext<State>['on'] = (event, hook) => router.add(event, hook)
|
|
359
359
|
const [session, root, startCwd] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd()])
|
|
360
360
|
let cwd = startCwd
|
|
361
361
|
let loadedCwd = startCwd
|
|
@@ -370,7 +370,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
370
370
|
if (!hookEvents.includes(event)) hookEvents.push(event)
|
|
371
371
|
router.add(`classic.${event}`, classicHook(definition.name, event, hook, claude, agents))
|
|
372
372
|
},
|
|
373
|
-
use: (
|
|
373
|
+
use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
|
|
374
374
|
ui: area.ui,
|
|
375
375
|
process: {
|
|
376
376
|
run: (argv, init) => claude.process.run(argv, init),
|
|
@@ -414,32 +414,32 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
414
414
|
claude.ui.log(`${definition.name} keeps the state of ${modState.root} until the next prompt or folder move: ${messageOf(error)}`)
|
|
415
415
|
}
|
|
416
416
|
}
|
|
417
|
-
|
|
417
|
+
router.add('classic.SessionStart', async (e, next) => {
|
|
418
418
|
await modState.switchSession(e.session_id, e.source).catch((error: unknown) => {
|
|
419
419
|
claude.ui.log(`${definition.name} kept its session values from before the ${e.source}: ${messageOf(error)}`)
|
|
420
420
|
})
|
|
421
421
|
return next(e)
|
|
422
422
|
})
|
|
423
|
-
|
|
423
|
+
router.add('classic.UserPromptSubmit', async (e, next) => {
|
|
424
424
|
staleCwd = undefined
|
|
425
425
|
await followSession()
|
|
426
426
|
return next(e)
|
|
427
427
|
})
|
|
428
428
|
for (const event of ['classic.PostToolUse', 'classic.PostToolUseFailure'] as const) {
|
|
429
|
-
|
|
429
|
+
router.add(event, async (e, next) => {
|
|
430
430
|
if (shellTools.includes(e.tool_name)) await followSession()
|
|
431
431
|
return next(e)
|
|
432
432
|
})
|
|
433
433
|
}
|
|
434
|
-
|
|
434
|
+
router.add('command.run', async (e, next) => {
|
|
435
435
|
const answer = await next(e)
|
|
436
436
|
if (e.command === 'cd') await followSession(true)
|
|
437
437
|
return answer
|
|
438
438
|
})
|
|
439
|
-
|
|
439
|
+
router.add('skill.prompt', userSkillHook(claude))
|
|
440
440
|
const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input: never) => run(input, mod)]))
|
|
441
|
-
|
|
442
|
-
const agents = toolCalls(
|
|
441
|
+
router.add('cmod.call', (e, next) => (e.to === definition.name ? answerCall(definition.name, api, e) : next(e)))
|
|
442
|
+
const agents = toolCalls(claude, router)
|
|
443
443
|
const names = new Set<string>()
|
|
444
444
|
const reserveName = (kind: string, name: string, taken: string) => {
|
|
445
445
|
const key = `${kind}:${name}`
|
|
@@ -448,6 +448,10 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
448
448
|
}
|
|
449
449
|
await failsAs('its state did not load', () => modState.load())
|
|
450
450
|
await failsAs('its setup function threw', () => definition.setup(mod))
|
|
451
|
+
const permissionHooks = permissionEvents.filter((event) => router.has(event))
|
|
452
|
+
if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
|
|
453
|
+
throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
|
|
454
|
+
}
|
|
451
455
|
await failsAs('its open panes did not load', () => area.restorePanes())
|
|
452
456
|
if (hookEvents.length > 0) added.push(`${hookEvents.length === 1 ? 'a hook' : 'hooks'} on ${listed(hookEvents)}`)
|
|
453
457
|
return { mod, added }
|
package/src/runtime/router.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: Route
|
|
|
7
7
|
|
|
8
8
|
export type Router = {
|
|
9
9
|
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): void
|
|
10
|
+
has(event: RoutedEvent): boolean
|
|
10
11
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -21,6 +22,7 @@ export function createRouter(): Router {
|
|
|
21
22
|
chain.push(hook as unknown as ErasedHook)
|
|
22
23
|
hooks.set(event, chain)
|
|
23
24
|
},
|
|
25
|
+
has: (event) => hooks.has(event),
|
|
24
26
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
25
27
|
const chain = hooks.get(event)
|
|
26
28
|
const call = async (index: number, current: unknown): Promise<unknown> => {
|