@cmodjs/core 0.1.8 → 0.1.9

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 (82) hide show
  1. package/docs/commands.md +2 -2
  2. package/docs/hooks.md +62 -6
  3. package/docs/index.md +6 -4
  4. package/docs/install-steps.md +3 -3
  5. package/docs/jobs.md +5 -4
  6. package/docs/mod.md +39 -7
  7. package/docs/testing.md +25 -9
  8. package/docs/ui.md +27 -3
  9. package/jobs/check.js +2 -2
  10. package/jobs/context.d.ts.map +1 -1
  11. package/jobs/context.js +2 -0
  12. package/jobs/permissions/decide-permission.js +2 -2
  13. package/jobs/permissions/find-project-scope.d.ts +1 -1
  14. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  15. package/jobs/permissions/find-project-scope.js +9 -9
  16. package/jobs/permissions/match-target.d.ts.map +1 -1
  17. package/jobs/permissions/match-target.js +4 -3
  18. package/mod.d.ts +15 -5
  19. package/mod.d.ts.map +1 -1
  20. package/package.json +2 -1
  21. package/path.d.ts +2 -0
  22. package/path.d.ts.map +1 -0
  23. package/path.js +1 -0
  24. package/register.d.ts.map +1 -1
  25. package/register.js +8 -2
  26. package/runtime/claude.d.ts +4 -1
  27. package/runtime/claude.d.ts.map +1 -1
  28. package/runtime/hooks.d.ts +10 -2
  29. package/runtime/hooks.d.ts.map +1 -1
  30. package/runtime/hooks.js +56 -18
  31. package/runtime/lifecycle.d.ts.map +1 -1
  32. package/runtime/lifecycle.js +12 -2
  33. package/runtime/ui.d.ts.map +1 -1
  34. package/runtime/ui.js +38 -8
  35. package/shell.d.ts +2 -0
  36. package/shell.d.ts.map +1 -0
  37. package/shell.js +1 -0
  38. package/src/jobs/check.ts +2 -2
  39. package/src/jobs/context.ts +2 -0
  40. package/src/jobs/permissions/decide-permission.ts +2 -2
  41. package/src/jobs/permissions/find-project-scope.ts +11 -10
  42. package/src/jobs/permissions/match-target.ts +4 -3
  43. package/src/mod.ts +22 -3
  44. package/src/path.ts +1 -0
  45. package/src/register.ts +8 -2
  46. package/src/runtime/claude.ts +11 -1
  47. package/src/runtime/hooks.ts +62 -23
  48. package/src/runtime/lifecycle.ts +11 -4
  49. package/src/runtime/ui.ts +35 -8
  50. package/src/shell.ts +1 -0
  51. package/src/testing/fake-claude.ts +14 -5
  52. package/src/testing/fake-elements.ts +5 -7
  53. package/src/testing/fake-files.ts +5 -5
  54. package/src/testing.ts +83 -11
  55. package/src/ui/define-pane.ts +5 -1
  56. package/src/utils/call-effects.ts +12 -11
  57. package/src/utils/parse-shell.ts +28 -36
  58. package/src/utils/paths.ts +4 -19
  59. package/src/vendor.ts +1 -0
  60. package/testing/fake-claude.d.ts +5 -0
  61. package/testing/fake-claude.d.ts.map +1 -1
  62. package/testing/fake-claude.js +13 -3
  63. package/testing/fake-elements.d.ts +5 -8
  64. package/testing/fake-elements.d.ts.map +1 -1
  65. package/testing/fake-elements.js +6 -6
  66. package/testing/fake-files.js +5 -5
  67. package/testing.d.ts +4 -2
  68. package/testing.d.ts.map +1 -1
  69. package/testing.js +65 -9
  70. package/ui/define-pane.d.ts +5 -1
  71. package/ui/define-pane.d.ts.map +1 -1
  72. package/utils/call-effects.d.ts.map +1 -1
  73. package/utils/call-effects.js +12 -11
  74. package/utils/parse-shell.d.ts +5 -9
  75. package/utils/parse-shell.d.ts.map +1 -1
  76. package/utils/parse-shell.js +5 -10
  77. package/utils/paths.d.ts +0 -3
  78. package/utils/paths.d.ts.map +1 -1
  79. package/utils/paths.js +3 -18
  80. package/vendor.d.ts +1 -0
  81. package/vendor.d.ts.map +1 -1
  82. package/vendor.js +209 -12
@@ -3,7 +3,7 @@ import { messageOf } from '../../utils/text.js'
3
3
  import { callBaseOf, callEffects, type CallEffects, type ToolCall, type ToolUse } from '../../utils/call-effects.js'
4
4
  import type { Workspace } from './find-project-scope.js'
5
5
  import { folderInScope, matchTarget, targetOf, type MatchedCall, type Target, type TargetCalls, type TargetKey, type TargetOf } from './match-target.js'
6
- import { resolvePath } from '../../utils/paths.js'
6
+ import { resolve } from '../../vendor.js'
7
7
 
8
8
  export type Rule<Mod> = {
9
9
  [Key in TargetKey]: TargetOf<Key> & {
@@ -71,7 +71,7 @@ async function unparsedMatches(target: Target, use: ToolUse, effects: CallEffect
71
71
  const named = patterns.map((pattern) => pattern.slice(pattern.lastIndexOf('/') + 1)).filter((segment) => segment !== '' && !/[*?[\]{}]/.test(segment) && line.includes(segment.toLowerCase()))
72
72
  const folder = named.length === 0 ? undefined : await folderInScope(shell, workspace)
73
73
  if (folder === undefined) return []
74
- return named.map((segment) => ({ call: { ...base, path: resolvePath(segment, folder) }, access: undefined, folder }))
74
+ return named.map((segment) => ({ call: { ...base, path: resolve(folder, segment) }, access: undefined, folder }))
75
75
  }
76
76
 
77
77
  function containsWord(line: string, word: string): boolean {
@@ -1,14 +1,15 @@
1
- import { nameOf, parentOf, resolvePath, type FileSystem } from '../../utils/paths.js'
1
+ import type { FileSystem } from '../../utils/paths.js'
2
+ import { basename, dirname, resolve } from '../../vendor.js'
2
3
 
3
4
  export type ProjectScope = { workTreeOf: (path: string) => Promise<string | undefined> }
4
5
 
5
6
  export type Workspace = { projectRoot: string; cwd: string; home: string; fs: FileSystem; scope: ProjectScope | undefined }
6
7
 
7
8
  export async function findProjectScope(pluginRoot: string, home: string, fs: FileSystem): Promise<ProjectScope | undefined> {
8
- const skills = parentOf(pluginRoot)
9
- const claude = parentOf(skills)
10
- const root = parentOf(claude)
11
- if (nameOf(skills) !== 'skills' || nameOf(claude) !== '.claude' || root === resolvePath(home, '/')) return undefined
9
+ const skills = dirname(pluginRoot)
10
+ const claude = dirname(skills)
11
+ const root = dirname(claude)
12
+ if (basename(skills) !== 'skills' || basename(claude) !== '.claude' || root === resolve('/', home)) return undefined
12
13
  const repositories = new Map<string, string>()
13
14
  const repositoryOf = async (workTree: string) => {
14
15
  const known = repositories.get(workTree)
@@ -21,7 +22,7 @@ export async function findProjectScope(pluginRoot: string, home: string, fs: Fil
21
22
  if (project === undefined) return undefined
22
23
  return {
23
24
  async workTreeOf(path) {
24
- for (let folder = path; ; folder = parentOf(folder)) {
25
+ for (let folder = path; ; folder = dirname(folder)) {
25
26
  const repository = await repositoryOf(folder)
26
27
  if (repository !== undefined) return repository === project ? folder : undefined
27
28
  if (folder === '/') return undefined
@@ -31,16 +32,16 @@ export async function findProjectScope(pluginRoot: string, home: string, fs: Fil
31
32
  }
32
33
 
33
34
  async function readRepository(workTree: string, fs: FileSystem): Promise<string | undefined> {
34
- const gitPath = resolvePath('.git', workTree)
35
+ const gitPath = resolve(workTree, '.git')
35
36
  const git = await fs.stat(gitPath).catch(() => undefined)
36
37
  if (git === undefined) return undefined
37
38
  if (git.kind === 'dir') return git.realPath ?? gitPath
38
39
  try {
39
40
  const gitDirectory = /^gitdir:\s*(.+)$/m.exec(await fs.read(gitPath))?.[1]?.trim()
40
41
  if (gitDirectory === undefined) throw new Error(`${gitPath} has no gitdir line.`)
41
- const linkedDirectory = resolvePath(gitDirectory, workTree)
42
- const commonDirectory = await fs.read(resolvePath('commondir', linkedDirectory)).then(
43
- (text) => resolvePath(text.trim(), linkedDirectory),
42
+ const linkedDirectory = resolve(workTree, gitDirectory)
43
+ const commonDirectory = await fs.read(resolve(linkedDirectory, 'commondir')).then(
44
+ (text) => resolve(linkedDirectory, text.trim()),
44
45
  () => linkedDirectory,
45
46
  )
46
47
  return (await fs.stat(commonDirectory)).realPath ?? commonDirectory
@@ -2,7 +2,8 @@ import { matchesGlob } from '../../utils/matches-glob.js'
2
2
  import { parseShell } from '../../utils/parse-shell.js'
3
3
  import { callBaseOf, type CallBase, type CallEffects, type CommandCall, type FetchCall, type FileAccess, type FileCall, type ToolCall, type ToolUse } from '../../utils/call-effects.js'
4
4
  import type { Workspace } from './find-project-scope.js'
5
- import { nameOf, realPathOf, relativePath } from '../../utils/paths.js'
5
+ import { realPathOf, relativePath } from '../../utils/paths.js'
6
+ import { basename } from '../../vendor.js'
6
7
 
7
8
  export type TargetCalls = { command: CommandCall; read: FileCall; write: FileCall; fetch: FetchCall; subagent: CallBase; tool: CallBase }
8
9
  export type TargetKey = keyof TargetCalls
@@ -56,11 +57,11 @@ export async function matchTarget(target: Target, use: ToolUse, effects: CallEff
56
57
  function commandPattern(pattern: string): [string, ...string[]] {
57
58
  const command = parseShell(pattern).commands[0]
58
59
  if (command === undefined) throw new Error(`The command pattern '${pattern}' names no program.`)
59
- return command
60
+ return command.argv
60
61
  }
61
62
 
62
63
  function commandMatches(pattern: [string, ...string[]], argv: [string, ...string[]]): boolean {
63
- if (nameOf(argv[0]) !== nameOf(pattern[0])) return false
64
+ if (basename(argv[0]) !== basename(pattern[0])) return false
64
65
  const wanted = splitArguments(pattern.slice(1))
65
66
  const actual = splitArguments(argv.slice(1))
66
67
  let next = 0
package/src/mod.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import type {
2
+ AgentSpawnArgs,
3
+ AgentSpawnResult,
2
4
  AskOptions,
3
5
  ClassicHookInputs,
4
6
  CmodDependencies,
5
7
  FsEntry,
8
+ FsStat,
9
+ FsStatOptions,
6
10
  HookStream,
7
11
  HttpInit,
8
12
  HttpResponse,
13
+ PaneOpenArgs,
9
14
  PermissionRequestDecision,
10
15
  ProcessRunInit,
11
16
  ProcessRunResult,
@@ -15,6 +20,8 @@ import type {
15
20
  RenderElement,
16
21
  Settings,
17
22
  SettingsReadArgs,
23
+ UiScrollArgs,
24
+ UiScrollResult,
18
25
  } from 'claude-code'
19
26
  import type { Claude } from './runtime/claude.js'
20
27
  import type { RoutedEvent } from './runtime/hooks.js'
@@ -30,9 +37,9 @@ export type ProgressStep = {
30
37
  }
31
38
 
32
39
  export type PaneHandle = {
33
- open(): Promise<void>
40
+ open(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>
34
41
  close(): Promise<void>
35
- toggle(): Promise<void>
42
+ toggle(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>
36
43
  readonly isOpen: boolean
37
44
  }
38
45
 
@@ -59,6 +66,7 @@ export type ModEvent =
59
66
  | 'Stop'
60
67
  | 'StopFailure'
61
68
  | 'CwdChanged'
69
+ | 'FileChanged'
62
70
 
63
71
  export type HookAnswer = {
64
72
  continue?: boolean
@@ -104,7 +112,9 @@ type HookInputs = Omit<ClassicHookInputs, 'PreToolUse' | 'PostToolUse' | 'PostTo
104
112
  CwdChanged: Omit<ClassicHookInputs['CwdChanged'], 'transcript_path'>
105
113
  }
106
114
 
107
- export type ModHook<E extends ModEvent> = (input: HookInputs[E]) => HookAnswer | void | Promise<HookAnswer | void>
115
+ export type HookInput<E extends ModEvent> = HookInputs[E]
116
+
117
+ export type ModHook<E extends ModEvent> = (input: HookInput<E>) => HookAnswer | void | Promise<HookAnswer | void>
108
118
 
109
119
  export type Mod<State extends object = Record<never, never>> = {
110
120
  readonly name: string
@@ -118,6 +128,7 @@ export type Mod<State extends object = Record<never, never>> = {
118
128
  toast(text: string): void
119
129
  progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
120
130
  ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
131
+ scroll(args: UiScrollArgs): Promise<UiScrollResult>
121
132
  }
122
133
  readonly process: {
123
134
  run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
@@ -127,6 +138,8 @@ export type Mod<State extends object = Record<never, never>> = {
127
138
  read(path: string): Promise<string>
128
139
  write(path: string, text: string): Promise<void>
129
140
  list(path?: string): Promise<FsEntry[]>
141
+ exists(path: string): Promise<boolean>
142
+ stat(path: string, options?: FsStatOptions): Promise<FsStat>
130
143
  }
131
144
  readonly http: {
132
145
  fetch(url: string, init?: HttpInit): Promise<HttpResponse>
@@ -134,6 +147,12 @@ export type Mod<State extends object = Record<never, never>> = {
134
147
  readonly settings: {
135
148
  read(args?: SettingsReadArgs): Promise<Settings>
136
149
  }
150
+ readonly session: {
151
+ messages: Claude['session']['messages']
152
+ }
153
+ readonly agent: {
154
+ spawn(args: AgentSpawnArgs): Promise<AgentSpawnResult>
155
+ }
137
156
  readonly projectRoot: string
138
157
  readonly cwd: string
139
158
  readonly dependencies: CmodDependencies
package/src/path.ts ADDED
@@ -0,0 +1 @@
1
+ export { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve } from './vendor.js'
package/src/register.ts CHANGED
@@ -21,6 +21,7 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
21
21
  open: (pane) => $.ui.open(pane),
22
22
  close: (pane) => $.ui.close(pane),
23
23
  panes: () => $.ui.panes(),
24
+ scroll: (args) => $.ui.scroll(args),
24
25
  resolve: (render) => $.ui.resolve(render),
25
26
  },
26
27
  process: {
@@ -54,10 +55,14 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
54
55
  model: () => $.session.model(),
55
56
  usage: () => $.session.usage(),
56
57
  surfaces: () => $.session.surfaces(),
58
+ messages: ((args?: Parameters<Claude['session']['messages']>[0]) => (args === undefined ? $.session.messages() : $.session.messages(args))) as Claude['session']['messages'],
57
59
  },
58
60
  command: { register: (command) => $.command.register(command) },
59
61
  tool: { register: (tool) => $.tool.register(tool) },
60
- agent: { list: () => $.agent.list() },
62
+ agent: {
63
+ list: () => $.agent.list(),
64
+ spawn: (args) => $.agent.spawn(args),
65
+ },
61
66
  env: {
62
67
  home: () => $.env.get('HOME'),
63
68
  dataHome: () => $.env.get('XDG_DATA_HOME'),
@@ -91,6 +96,7 @@ export function registerMod<State extends object>(addHook: On, definition: ModDe
91
96
  addHook('classic.PreCompact', routeToMod)
92
97
  addHook('classic.Stop', routeToMod)
93
98
  addHook('classic.StopFailure', routeToMod)
99
+ addHook('classic.FileChanged', routeToMod)
94
100
  addHook('tool.call', routeToMod)
95
101
  addHook('prompt.submit', routeToMod)
96
102
  addHook('prompt.context', routeToMod)
@@ -99,6 +105,7 @@ export function registerMod<State extends object>(addHook: On, definition: ModDe
99
105
  addHook('skill.prompt', routeToMod)
100
106
  addHook('ui.render', routeToMod)
101
107
  addHook('ui.press', routeToMod)
108
+ addHook('ui.scroll', routeToMod)
102
109
  addHook('ui.close', routeToMod)
103
110
  addHook('cmod.call', routeToMod)
104
111
  }
@@ -106,6 +113,5 @@ export function registerMod<State extends object>(addHook: On, definition: ModDe
106
113
  export function registerPermissionCheck(addHook: On): void {
107
114
  checksPermissions = true
108
115
  addHook('tool.check', routeToMod)
109
- addHook('classic.PreToolUse', routeToMod)
110
116
  addHook('classic.PermissionRequest', routeToMod)
111
117
  }
@@ -1,6 +1,9 @@
1
1
  import type {
2
2
  AgentInfo,
3
+ AgentSpawnArgs,
4
+ AgentSpawnResult,
3
5
  Args,
6
+ EngineInterface,
4
7
  AskOptions,
5
8
  CommandSpec,
6
9
  ElementTable,
@@ -29,6 +32,8 @@ import type {
29
32
  UiLogOptions,
30
33
  UiOpenResult,
31
34
  UiPane,
35
+ UiScrollArgs,
36
+ UiScrollResult,
32
37
  } from 'claude-code'
33
38
 
34
39
  export type Claude = {
@@ -43,6 +48,7 @@ export type Claude = {
43
48
  open(pane: PaneOpenArgs): Promise<UiOpenResult>
44
49
  close(pane: PaneCloseArgs): Promise<void>
45
50
  panes(): Promise<readonly UiPane[]>
51
+ scroll(args: UiScrollArgs): Promise<UiScrollResult>
46
52
  resolve(e: ResolveInput): ElementTable<RenderSurface>
47
53
  }
48
54
  readonly process: {
@@ -76,10 +82,14 @@ export type Claude = {
76
82
  model(): Promise<string>
77
83
  usage(): Promise<SessionUsage>
78
84
  surfaces(): Promise<readonly RenderSurface[]>
85
+ messages: EngineInterface['session']['messages']
79
86
  }
80
87
  readonly command: { register(command: CommandSpec): Promise<{ command: string }> }
81
88
  readonly tool: { register(tool: ToolSpec): Promise<{ tool: string }> }
82
- readonly agent: { list(): Promise<AgentInfo[]> }
89
+ readonly agent: {
90
+ list(): Promise<AgentInfo[]>
91
+ spawn(args: AgentSpawnArgs): Promise<AgentSpawnResult>
92
+ }
83
93
  readonly env: {
84
94
  home(): Promise<string | undefined>
85
95
  dataHome(): Promise<string | undefined>
@@ -1,15 +1,15 @@
1
- import type { Args, ClassicHookInputs, Frozen } from 'claude-code'
2
- import type { HookAnswer, ModEvent, ModHook } from '../mod.js'
1
+ import type { Args, ClassicHookInputs, EventResult, Frozen } from 'claude-code'
2
+ import type { HookAnswer, HookInput, ModEvent, ModHook } from '../mod.js'
3
3
  import { configFolders } from '../records.js'
4
4
  import type { Claude } from './claude.js'
5
5
  import type { RoutedHook } from './router.js'
6
- import { toolInputOf, type ToolCalls } from './tool-calls.js'
6
+ import { reservedKeys, toolInputOf, type ToolCalls } from './tool-calls.js'
7
7
  import { callEffects, type FileAccess, type ToolUse } from '../utils/call-effects.js'
8
8
  import { dynamicPattern } from '../utils/parse-shell.js'
9
9
  import { messageOf } from '../utils/text.js'
10
10
 
11
11
  export type RoutedEvent =
12
- | `classic.${ModEvent}`
12
+ | `classic.${Exclude<ModEvent, 'PreToolUse'>}`
13
13
  | 'tool.check'
14
14
  | 'tool.call'
15
15
  | 'prompt.submit'
@@ -19,16 +19,15 @@ export type RoutedEvent =
19
19
  | 'skill.prompt'
20
20
  | 'ui.render'
21
21
  | 'ui.press'
22
+ | 'ui.scroll'
22
23
  | 'ui.close'
23
24
  | 'cmod.call'
24
25
 
25
- export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PreToolUse', 'classic.PermissionRequest']
26
+ export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
26
27
 
27
28
  type ClassicFields = Record<string, unknown>
28
29
 
29
- type PreToolUseInput = Parameters<ModHook<'PreToolUse'>>[0]
30
-
31
- type CallFiles = PreToolUseInput['files']
30
+ type CallFiles = HookInput<'PreToolUse'>['files']
32
31
 
33
32
  const flags: readonly string[] = ['suppressOriginalPrompt', 'reloadSkills', 'retry']
34
33
 
@@ -50,18 +49,18 @@ const readFields: Record<ModEvent, readonly string[]> = {
50
49
  Stop: ['additionalContext'],
51
50
  StopFailure: [],
52
51
  CwdChanged: [],
52
+ FileChanged: [],
53
53
  }
54
54
 
55
- export function classicHook<E extends ModEvent>(name: string, event: E, hook: ModHook<E>, claude: Claude, calls: ToolCalls): RoutedHook<RoutedEvent> {
55
+ export function classicHook<E extends Exclude<ModEvent, 'PreToolUse'>>(name: string, event: E, hook: ModHook<E>, claude: Claude, calls: ToolCalls): RoutedHook<RoutedEvent> {
56
56
  const inputOf = async (e: unknown): Promise<unknown> => {
57
- if (event === 'PreToolUse') return preToolUseInput(name, e as Frozen<Args<'classic.PreToolUse'>>, claude, calls.agentOf)
58
57
  if (event !== 'PostToolUse' && event !== 'PostToolUseFailure') return e
59
58
  const input = e as ClassicHookInputs['PostToolUse' | 'PostToolUseFailure']
60
59
  const cwd = calls.cwdOf(input.tool_use_id) ?? input.cwd
61
60
  return { ...input, files: await callFiles(name, claude, { tool: input.tool_name, input: input.tool_input }, cwd) }
62
61
  }
63
62
  const resultOf = async (e: unknown): Promise<ClassicFields | undefined> => {
64
- const answer = await hook((await inputOf(e)) as Parameters<ModHook<E>>[0])
63
+ const answer = await hook((await inputOf(e)) as HookInput<E>)
65
64
  if (answer === undefined) return undefined
66
65
  if (answer.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
67
66
  return classicResult(event, answer)
@@ -69,17 +68,65 @@ export function classicHook<E extends ModEvent>(name: string, event: E, hook: Mo
69
68
  const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
70
69
  const ours = await resultOf(e).catch((error: unknown): ClassicFields | undefined => {
71
70
  const reason = `${name}: the ${event} hook failed: ${messageOf(error)}`
72
- if (event === 'PreToolUse') return { deny: reason }
73
71
  if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: reason } }
74
72
  claude.ui.log(reason)
75
73
  return undefined
76
74
  })
77
75
  if (ours === undefined) return next(e)
78
- return mergeClassic(event, (await next(e)) as ClassicFields, ours)
76
+ return mergeClassic((await next(e)) as ClassicFields, ours)
79
77
  }
80
78
  return routed as unknown as RoutedHook<RoutedEvent>
81
79
  }
82
80
 
81
+ type HeldDecision = { readonly decision: 'allow' | 'ask'; readonly reason?: string }
82
+
83
+ export type HeldDecisions = Map<string, HeldDecision>
84
+
85
+ export function preToolUseHook(name: string, hook: ModHook<'PreToolUse'>, claude: Claude, calls: ToolCalls, held: HeldDecisions | undefined): RoutedHook<'tool.call'> {
86
+ return async (e, next) => {
87
+ let ours: ClassicFields
88
+ try {
89
+ const answer = await hook(await preToolUseInput(name, e, claude, calls.agentOf))
90
+ if (answer?.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
91
+ ours = answer === undefined ? {} : classicResult('PreToolUse', answer)
92
+ } catch (error) {
93
+ return { deny: `${name}: the PreToolUse hook failed: ${messageOf(error)}` }
94
+ }
95
+ if (typeof ours['deny'] === 'string') return { deny: ours['deny'] }
96
+ const decided = heldDecisionOf(ours)
97
+ if (decided !== undefined && held === undefined) {
98
+ return { deny: `${name}: the PreToolUse hook answered permissionDecision "${decided.decision}", so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.` }
99
+ }
100
+ const updated = ours['updatedInput'] as Record<string, unknown> | undefined
101
+ const reserved = Object.fromEntries(Object.entries(e).filter(([key]) => reservedKeys.includes(key)))
102
+ const earlier = held?.get(e.tool_use_id)
103
+ if (decided !== undefined) held?.set(e.tool_use_id, earlier !== undefined && strictness.indexOf(earlier.decision) >= strictness.indexOf(decided.decision) ? earlier : decided)
104
+ let result: EventResult<'tool.call'>
105
+ try {
106
+ result = await next(updated === undefined ? e : ({ ...reserved, ...updated } as Frozen<Args<'tool.call'>>))
107
+ } finally {
108
+ if (decided !== undefined) held?.delete(e.tool_use_id)
109
+ }
110
+ const context = ours['additionalContext'] as string[] | undefined
111
+ if (context === undefined || result.deny !== undefined) return result
112
+ return { ...result, context: [...(result.context ?? []), ...context] }
113
+ }
114
+ }
115
+
116
+ export function heldDecisionHook(held: HeldDecisions): RoutedHook<'tool.check'> {
117
+ return async (e, next) => {
118
+ const below = await next(e)
119
+ const ours = e.tool_use_id === undefined ? undefined : held.get(e.tool_use_id)
120
+ return ours === undefined || below.decision === 'deny' ? below : ours
121
+ }
122
+ }
123
+
124
+ function heldDecisionOf(ours: ClassicFields): HeldDecision | undefined {
125
+ if (ours['allow'] !== undefined) return { decision: 'allow' }
126
+ if (typeof ours['ask'] !== 'string') return undefined
127
+ return ours['ask'] === '' ? { decision: 'ask' } : { decision: 'ask', reason: ours['ask'] }
128
+ }
129
+
83
130
  const frontmatter = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/
84
131
 
85
132
  const baseDirectoryLine = /^Base directory for this skill: [^\n]*\n\n/
@@ -101,7 +148,7 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
101
148
  }
102
149
  }
103
150
 
104
- async function preToolUseInput(name: string, envelope: Frozen<Args<'classic.PreToolUse'>>, claude: Claude, agentOf: ToolCalls['agentOf']): Promise<PreToolUseInput> {
151
+ async function preToolUseInput(name: string, envelope: Frozen<Args<'tool.call'>>, claude: Claude, agentOf: ToolCalls['agentOf']): Promise<HookInput<'PreToolUse'>> {
105
152
  const { tool, tool_use_id } = envelope
106
153
  const [session_id, cwd, { agentId, agentType }] = await Promise.all([claude.session.id(), claude.session.cwd(), agentOf(tool_use_id)])
107
154
  const tool_input = toolInputOf(envelope)
@@ -161,17 +208,9 @@ function classicResult(event: ModEvent, answer: HookAnswer): ClassicFields {
161
208
 
162
209
  export const strictness = ['allow', 'ask', 'deny'] as const
163
210
 
164
- function strictnessOf(result: ClassicFields): number {
165
- return strictness.findLastIndex((decision) => result[decision] !== undefined)
166
- }
167
-
168
- function mergeClassic(event: ModEvent, below: ClassicFields, ours: ClassicFields): ClassicFields {
211
+ function mergeClassic(below: ClassicFields, ours: ClassicFields): ClassicFields {
169
212
  const merged: ClassicFields = { ...below, ...ours }
170
213
  const context = [...((below['additionalContext'] as string[] | undefined) ?? []), ...((ours['additionalContext'] as string[] | undefined) ?? [])]
171
214
  if (context.length > 0) merged['additionalContext'] = context
172
- if (event !== 'PreToolUse') return merged
173
- const strictest = strictness[Math.max(strictnessOf(below), strictnessOf(ours))]
174
- for (const decision of strictness) delete merged[decision]
175
- if (strictest !== undefined) merged[strictest] = ours[strictest] ?? below[strictest]
176
215
  return merged
177
216
  }
@@ -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 } from '../mod.js'
2
+ import type { HookInput, 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, permissionEvents, userSkillHook, type RoutedEvent } from './hooks.js'
9
+ import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, 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'
@@ -362,13 +362,15 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
362
362
  let staleCwd: string | undefined
363
363
  const area = createUi<State>({ name: definition.name, claude, router, progress, announce, mod: () => mod })
364
364
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed })
365
+ const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
365
366
  const mod: Mod<State> = {
366
367
  name: definition.name,
367
368
  state: modState.state,
368
369
  dataFolder: runtime.dataFolder,
369
370
  on(event, hook) {
370
371
  if (!hookEvents.includes(event)) hookEvents.push(event)
371
- router.add(`classic.${event}`, classicHook(definition.name, event, hook, claude, agents))
372
+ if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, hook as ModHook<'PreToolUse'>, claude, agents, held))
373
+ else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>, hook as ModHook<Exclude<ModEvent, 'PreToolUse'>>, claude, agents))
372
374
  },
373
375
  use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
374
376
  ui: area.ui,
@@ -380,9 +382,13 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
380
382
  read: (path) => claude.fs.read(path),
381
383
  write: (path, text) => claude.fs.write(path, text),
382
384
  list: (path) => claude.fs.list(path),
385
+ exists: (path) => claude.fs.exists(path),
386
+ stat: (path, options) => claude.fs.stat(path, options),
383
387
  },
384
388
  http: { fetch: (url, init) => claude.http.fetch(url, init) },
385
389
  settings: { read: (args) => claude.settings.read(args) },
390
+ session: { messages: claude.session.messages },
391
+ agent: { spawn: (args) => claude.agent.spawn(args) },
386
392
  get projectRoot() {
387
393
  return modState.root
388
394
  },
@@ -408,7 +414,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
408
414
  loadedCwd = nextCwd
409
415
  if (!hasMovedRoot) area.changed()
410
416
  if (nextCwd === oldCwd) return
411
- const moved: Parameters<ModHook<'CwdChanged'>>[0] = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
417
+ const moved: HookInput<'CwdChanged'> = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
412
418
  await router.dispatch('classic.CwdChanged', moved as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
413
419
  } catch (error) {
414
420
  claude.ui.log(`${definition.name} keeps the state of ${modState.root} until the next prompt or folder move: ${messageOf(error)}`)
@@ -452,6 +458,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
452
458
  if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
453
459
  throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
454
460
  }
461
+ if (held !== undefined && hookEvents.includes('PreToolUse')) router.add('tool.check', heldDecisionHook(held))
455
462
  await failsAs('its open panes did not load', () => area.restorePanes())
456
463
  if (hookEvents.length > 0) added.push(`${hookEvents.length === 1 ? 'a hook' : 'hooks'} on ${listed(hookEvents)}`)
457
464
  return { mod, added }
package/src/runtime/ui.ts CHANGED
@@ -224,6 +224,7 @@ export type UiArea<State extends object> = {
224
224
 
225
225
  export function createUi<State extends object>({ name, claude, router, progress, announce, mod }: UiOptions<State>): UiArea<State> {
226
226
  const panes = new Map<string, Pane<State>>()
227
+ const paneLogs = new Map<string, (failure: string) => void>()
227
228
  const openPanes = new Set<string>()
228
229
  const requestedSizes = new Map<string, PaneSize>()
229
230
  const renders = new Set<string>()
@@ -291,9 +292,10 @@ export function createUi<State extends object>({ name, claude, router, progress,
291
292
  })
292
293
  }
293
294
 
294
- const openAt = (pane: Pane<State>, size: PaneSize) => {
295
+ const openAt = (pane: Pane<State>, size: PaneSize, focus?: true) => {
295
296
  requestedSizes.set(pane.id, size)
296
- return claude.ui.open({ id: pane.id, title: pane.title, ...size })
297
+ const { id, title, closeOnEscape, holdToasts } = pane
298
+ return claude.ui.open({ id, title, ...size, ...(closeOnEscape === true ? { closeOnEscape } : {}), ...(holdToasts === true ? { holdToasts } : {}), ...(focus === true ? { focus } : {}) })
297
299
  }
298
300
 
299
301
  const resize = async (pane: Pane<State>) => {
@@ -315,34 +317,58 @@ export function createUi<State extends object>({ name, claude, router, progress,
315
317
  if (pane === undefined || e.component !== 'Pane') return next(e)
316
318
  openPanes.add(pane.id)
317
319
  const table = claude.ui.resolve(e)
318
- return drawWith(table, () => pane.render(mod(), e.props), markdownIn(table))
320
+ try {
321
+ return drawWith(table, () => pane.render(mod(), e.props), markdownIn(table))
322
+ } catch (error) {
323
+ paneLogs.get(pane.id)?.(`threw: ${messageOf(error)}`)
324
+ return drawWith(table, () => Text({ color: 'error', children: `The ${pane.title} pane could not draw: ${messageOf(error)}` }))
325
+ }
326
+ })
327
+ router.add('ui.scroll', async (e, next) => {
328
+ const pane = panes.get(e.requestId)
329
+ const moved = await next(e)
330
+ if (pane?.onScroll === undefined || e.component !== 'Pane' || moved.deny !== undefined) return moved
331
+ await handle(pane, 'onScroll', () => pane.onScroll?.(mod(), e))
332
+ return moved
319
333
  })
320
- router.add('ui.close', (e, next) => {
334
+ router.add('ui.close', async (e, next) => {
321
335
  openPanes.delete(e.id)
322
- return next(e)
336
+ const closed = await next(e)
337
+ const pane = panes.get(e.id)
338
+ if (pane?.onClose !== undefined) await handle(pane, 'onClose', () => pane.onClose?.(mod(), e))
339
+ return closed
323
340
  })
324
341
  }
325
342
 
343
+ const handle = async (pane: Pane<State>, handler: string, run: () => unknown) => {
344
+ try {
345
+ await run()
346
+ } catch (error) {
347
+ claude.ui.log(`${name}: the ${pane.title} pane's ${handler} threw: ${messageOf(error)}`)
348
+ }
349
+ }
350
+
326
351
  return {
327
352
  ui: {
328
353
  pane(pane) {
329
354
  if (panes.has(pane.id)) throw new Error(`${name}: the pane "${pane.id}" is already added. Give each pane its own id.`)
330
355
  panes.set(pane.id, pane)
356
+ paneLogs.set(pane.id, logOnce(`${pane.title} pane`))
331
357
  announce(`the ${pane.title} pane`)
332
358
  route()
333
359
  const handle: PaneHandle = {
334
360
  get isOpen() {
335
361
  return openPanes.has(pane.id)
336
362
  },
337
- async open() {
338
- const { isPlaced } = await openAt(pane, paneSize(pane, mod().state))
363
+ async open(options) {
364
+ const { isPlaced } = await openAt(pane, paneSize(pane, mod().state), options?.focus)
339
365
  if (isPlaced) openPanes.add(pane.id)
340
366
  },
341
367
  async close() {
342
368
  await claude.ui.close({ id: pane.id })
343
369
  openPanes.delete(pane.id)
344
370
  },
345
- toggle: () => (openPanes.has(pane.id) ? handle.close() : handle.open()),
371
+ toggle: (options) => (openPanes.has(pane.id) ? handle.close() : handle.open(options)),
346
372
  }
347
373
  return handle
348
374
  },
@@ -386,6 +412,7 @@ export function createUi<State extends object>({ name, claude, router, progress,
386
412
  }
387
413
  },
388
414
  ask: (question, options) => claude.ui.ask(question, options),
415
+ scroll: (args) => claude.ui.scroll(args),
389
416
  },
390
417
  changed() {
391
418
  if (panes.size === 0 && renders.size === 0) return
package/src/shell.ts ADDED
@@ -0,0 +1 @@
1
+ export { parseShell, type ParsedShell, type ShellCommand } from './utils/parse-shell.js'
@@ -12,8 +12,9 @@ export type Fakes = {
12
12
  fs: { read?: Claude['fs']['read']; write?: Claude['fs']['write']; list?: Claude['fs']['list']; exists?: Claude['fs']['exists']; stat?: Claude['fs']['stat'] }
13
13
  http: { fetch?: Claude['http']['fetch'] }
14
14
  settings: { read?: Claude['settings']['read'] }
15
- ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open'] }
16
- agent: { list?: Claude['agent']['list'] }
15
+ ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open']; scroll?: Claude['ui']['scroll'] }
16
+ session: { messages?: Claude['session']['messages'] }
17
+ agent: { list?: Claude['agent']['list']; spawn?: Claude['agent']['spawn'] }
17
18
  clock: { after?: Claude['clock']['after']; every?: Claude['clock']['every'] }
18
19
  cmod: { call?: Claude['cmod']['call'] }
19
20
  }
@@ -39,9 +40,10 @@ export type FakeClaude = {
39
40
 
40
41
  export function fakeClaude(plugin: { readonly name: string; readonly root: string }): FakeClaude {
41
42
  const calls: TestCall[] = []
42
- const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, ui: {}, agent: {}, clock: {}, cmod: {} }
43
+ const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, ui: {}, session: {}, agent: {}, clock: {}, cmod: {} }
43
44
  const shown: Shown = { toasts: [], logs: [], debug: [], statuses: [], openPanes: new Set(), commands: [], tools: [] }
44
45
  const unplacedPanes = new Set<string>()
46
+ let focusedPane: string | undefined
45
47
  const store = new Map<string, unknown>()
46
48
 
47
49
  const faked = <Args extends readonly unknown[], Result>(call: string, answer: () => ((...args: Args) => Result) | undefined) => {
@@ -81,6 +83,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
81
83
  if (answer.isPlaced) {
82
84
  shown.openPanes.add(pane.id)
83
85
  unplacedPanes.delete(pane.id)
86
+ if (pane.focus === true) focusedPane = pane.id
84
87
  } else if (!shown.openPanes.has(pane.id)) {
85
88
  unplacedPanes.add(pane.id)
86
89
  }
@@ -90,11 +93,13 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
90
93
  calls.push({ call: 'ui.close', args: [pane] })
91
94
  shown.openPanes.delete(pane.id)
92
95
  unplacedPanes.delete(pane.id)
96
+ if (focusedPane === pane.id) focusedPane = undefined
93
97
  },
94
98
  panes: async () => [
95
- ...[...shown.openPanes].map((id) => ({ id, title: id, isShown: true, isFocused: false, isPlaced: true })),
99
+ ...[...shown.openPanes].map((id) => ({ id, title: id, isShown: true, isFocused: id === focusedPane, isPlaced: true })),
96
100
  ...[...unplacedPanes].map((id) => ({ id, title: id, isShown: false, isFocused: false, isPlaced: false })),
97
101
  ],
102
+ scroll: rejected('ui.scroll', () => fakes.ui.scroll ?? (async () => ({}))),
98
103
  resolve: () => elements,
99
104
  },
100
105
  process: {
@@ -110,7 +115,10 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
110
115
  },
111
116
  http: { fetch: rejected('http.fetch', () => fakes.http.fetch) },
112
117
  settings: { read: rejected('settings.read', () => fakes.settings.read) },
113
- agent: { list: rejected('agent.list', () => fakes.agent.list) },
118
+ agent: {
119
+ list: rejected('agent.list', () => fakes.agent.list),
120
+ spawn: rejected('agent.spawn', () => fakes.agent.spawn),
121
+ },
114
122
  store: {
115
123
  get: async (key) => store.get(key),
116
124
  set: async (key, value) => {
@@ -133,6 +141,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
133
141
  model: async () => 'test-model',
134
142
  usage: async () => ({ context: { window: 200000 }, rateLimits: {}, cost: { usd: 0 } }) as unknown as Awaited<ReturnType<Claude['session']['usage']>>,
135
143
  surfaces: async () => ['terminal'],
144
+ messages: rejected('session.messages', () => fakes.session.messages as ((args?: unknown) => Promise<unknown>) | undefined) as Claude['session']['messages'],
136
145
  },
137
146
  command: {
138
147
  register: async (command) => {