@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.
Files changed (66) hide show
  1. package/docs/commands.md +4 -3
  2. package/docs/hooks.md +2 -0
  3. package/docs/index.md +4 -4
  4. package/docs/jobs.md +23 -22
  5. package/docs/mod.md +24 -9
  6. package/docs/testing.md +1 -1
  7. package/jobs/check.d.ts +2 -2
  8. package/jobs/check.d.ts.map +1 -1
  9. package/jobs/check.js +5 -5
  10. package/jobs/context.d.ts +14 -0
  11. package/jobs/context.d.ts.map +1 -0
  12. package/jobs/{part-context.js → context.js} +6 -6
  13. package/jobs/permissions.d.ts +2 -2
  14. package/jobs/permissions.d.ts.map +1 -1
  15. package/jobs/permissions.js +7 -7
  16. package/jobs/program.d.ts +2 -2
  17. package/jobs/program.d.ts.map +1 -1
  18. package/jobs/prompt.d.ts +2 -2
  19. package/jobs/prompt.d.ts.map +1 -1
  20. package/jobs/prompt.js +17 -17
  21. package/jobs/slash-command.d.ts +2 -2
  22. package/jobs/slash-command.d.ts.map +1 -1
  23. package/jobs/slash-command.js +5 -5
  24. package/jobs/status-line.d.ts +2 -2
  25. package/jobs/status-line.d.ts.map +1 -1
  26. package/jobs/status-line.js +5 -5
  27. package/jobs/tool.d.ts +2 -2
  28. package/jobs/tool.d.ts.map +1 -1
  29. package/jobs/tool.js +5 -5
  30. package/mod.d.ts +3 -3
  31. package/mod.d.ts.map +1 -1
  32. package/package.json +2 -2
  33. package/register.d.ts +5 -0
  34. package/register.d.ts.map +1 -0
  35. package/{connect.js → register.js} +42 -39
  36. package/runtime/hooks.d.ts +1 -0
  37. package/runtime/hooks.d.ts.map +1 -1
  38. package/runtime/hooks.js +1 -0
  39. package/runtime/lifecycle.d.ts +1 -1
  40. package/runtime/lifecycle.d.ts.map +1 -1
  41. package/runtime/lifecycle.js +16 -13
  42. package/runtime/router.d.ts +1 -0
  43. package/runtime/router.d.ts.map +1 -1
  44. package/runtime/router.js +1 -0
  45. package/runtime/tool-calls.d.ts +3 -2
  46. package/runtime/tool-calls.d.ts.map +1 -1
  47. package/runtime/tool-calls.js +11 -14
  48. package/src/jobs/check.ts +7 -7
  49. package/src/jobs/{part-context.ts → context.ts} +10 -10
  50. package/src/jobs/permissions.ts +9 -9
  51. package/src/jobs/program.ts +2 -2
  52. package/src/jobs/prompt.ts +19 -19
  53. package/src/jobs/slash-command.ts +7 -7
  54. package/src/jobs/status-line.ts +7 -7
  55. package/src/jobs/tool.ts +7 -7
  56. package/src/mod.ts +3 -3
  57. package/src/{connect.ts → register.ts} +44 -40
  58. package/src/runtime/hooks.ts +2 -0
  59. package/src/runtime/lifecycle.ts +18 -14
  60. package/src/runtime/router.ts +2 -0
  61. package/src/runtime/tool-calls.ts +12 -14
  62. package/vendor.js +1 -3
  63. package/connect.d.ts +0 -4
  64. package/connect.d.ts.map +0 -1
  65. package/jobs/part-context.d.ts +0 -14
  66. 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 { Mod, PartContext } from '../mod.js'
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 }: PartContext): () => Promise<Workspace> {
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
- part: PartContext<State>,
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(part)
43
- part.on('tool.call', async (e, next) => {
44
- const before = Promise.all([useOf(part, e), readWorkspace()])
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 }: PartContext, e: Frozen<Args<'tool.call'>>): Promise<ToolUse> {
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>(context: PartContext<State>, { call, folder }: { readonly call?: ToolCall; readonly folder: string }, workspace: Workspace, deadline: Deadline): Mod<State> {
58
- return modWithin(context, deadline, async () => workspace.scope?.workTreeOf(call !== undefined && 'path' in call ? call.path : folder))
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 }: PartContext<State>, deadline: Deadline, workTree: () => Promise<string | undefined> = async () => undefined): Mod<State> {
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())
@@ -1,11 +1,11 @@
1
1
  import type { Args, Frozen } from 'claude-code'
2
- import type { Mod, Part } from '../mod.js'
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 './part-context.js'
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>): Part<void, 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 (context) => {
23
- const readWorkspace = workspaceReader(context)
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([context.toolCalls.agentOf(e.tool_use_id), readWorkspace()])
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(context, { call, folder }, workspace, deadline))
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
- context.on('tool.check', async (e, next) => {
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
- context.announce(`${count} permission rule${count === 1 ? '' : 's'}`)
39
+ job.announce(`${count} permission rule${count === 1 ? '' : 's'}`)
40
40
  }
41
41
  }
@@ -1,4 +1,4 @@
1
- import type { Part } from '../mod.js'
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> }): Part<Program> {
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
@@ -1,9 +1,9 @@
1
- import type { Mod, Part, PartContext } from '../mod.js'
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 './part-context.js'
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>): Part<void, 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 (part) => {
26
- part.reserveName('prompt', name, `prompt: ${part.mod.name} adds the name "${name}" two times. Give each prompt its own name.`)
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) => part.claude.ui.log(`prompt "${name}" added nothing: ${reason}`, { to: 'debug' })
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(part, when, (userPrompt) => headedText({ userPrompt }, modWithin(part, deadline)), log)
46
- part.announce(`the ${name} prompt after matching user prompts`)
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(part, targets, headedText, log)
49
- part.announce(`the ${name} prompt after ${listed(targets.map(targetWords))}`)
48
+ addAfterCalls(job, targets, headedText, log)
49
+ job.announce(`the ${name} prompt after ${listed(targets.map(targetWords))}`)
50
50
  } else {
51
- addOncePerConversation(part, name, () => textFor({}, modWithin(part, deadline)), log)
52
- part.announce(`the ${name} prompt`)
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(part: PartContext, name: string, textFor: () => Promise<string | undefined>, log: (reason: string) => void): void {
58
- part.on('prompt.context', async (e, next) => {
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(part: PartContext, when: RegExp | ((userPrompt: string) => boolean), textFor: (userPrompt: string) => Promise<string | undefined>, log: (reason: string) => void): void {
70
- part.on('prompt.submit', async (e, next) => {
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
- part: PartContext<State>,
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
- part,
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(part, matched, workspace, deadline))
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 { Mod, Part } from '../mod.js'
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 './part-context.js'
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
- }): Part<void, State> {
20
+ }): Job<void, State> {
21
21
  const { name, description, argumentHint, immediate, reply } = options
22
- return (part) => {
23
- const { mod, claude, on, announce, reserveName } = part
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(part, deadline)
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))
@@ -1,7 +1,7 @@
1
- import type { Mod, Part } from '../mod.js'
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 './part-context.js'
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
- }): Part<void, State> {
17
+ }): Job<void, State> {
18
18
  const { text, interval = 10000 } = options
19
- return (part) => {
20
- const { mod, claude, on, announce, reserveName } = part
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(part, deadline)
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 { Mod, Part } from '../mod.js'
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 './part-context.js'
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
- }): Part<{ readonly name: string }, State> {
16
+ }): Job<{ readonly name: string }, State> {
17
17
  const { name, description, inputSchema, execute } = options
18
- return (part) => {
19
- const { mod, claude, on, announce, reserveName } = part
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(part, toolDeadline)
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>(part: Part<Handle, State>): 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 PartContext<State extends object = Record<never, never>> = {
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 Part<Handle, State extends object = Record<never, never>> = (context: PartContext<State>) => Handle
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
- async function startMod($: EngineInterface, e: Frozen<Args<'session.start'>>, next: Next<'session.start'>): Promise<EventResult<'session.start'>> {
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 claudeOf($: EngineInterface): Claude {
19
- return {
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 connect<State extends object>(on: On, definition: ModDefinition<State>): void {
78
- lifecycle = createLifecycle(definition)
79
- on('session.start', startMod)
80
- on('classic.SessionStart', routeToMod)
81
- on('classic.SessionEnd', routeToMod)
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
  }
@@ -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]
@@ -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, PartContext } from '../mod.js'
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>): Lifecycle<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. connect(on, mod) starts it at session.start.`)
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: (part) => part({ mod, claude, on, announce, reserveName, toolCalls: agents }),
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
- on('classic.SessionStart', async (e, next) => {
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
- on('classic.UserPromptSubmit', async (e, next) => {
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
- on(event, async (e, next) => {
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
- on('command.run', async (e, next) => {
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
- on('skill.prompt', userSkillHook(claude))
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
- on('cmod.call', (e, next) => (e.to === definition.name ? answerCall(definition.name, api, e) : next(e)))
442
- const agents = toolCalls({ claude, on })
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 }
@@ -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> => {