@cmodjs/core 3.0.0 → 4.0.0

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 (201) hide show
  1. package/docs/commands.md +39 -22
  2. package/docs/dependencies.md +27 -20
  3. package/docs/index.md +21 -17
  4. package/docs/install-steps.md +37 -29
  5. package/docs/jobs.md +25 -52
  6. package/docs/mod.md +94 -66
  7. package/docs/options.md +2 -2
  8. package/docs/permissions.md +7 -5
  9. package/docs/session.md +14 -12
  10. package/docs/testing.md +55 -32
  11. package/docs/ui.md +255 -263
  12. package/hotkeys.d.ts +22 -0
  13. package/hotkeys.d.ts.map +1 -0
  14. package/hotkeys.js +26 -0
  15. package/installer.d.ts +1 -1
  16. package/installer.d.ts.map +1 -1
  17. package/installer.js +11 -6
  18. package/jobs/check.d.ts +2 -1
  19. package/jobs/check.d.ts.map +1 -1
  20. package/jobs/check.js +2 -2
  21. package/jobs/context.d.ts +2 -1
  22. package/jobs/context.d.ts.map +1 -1
  23. package/jobs/context.js +1 -1
  24. package/jobs/permissions.d.ts +2 -1
  25. package/jobs/permissions.d.ts.map +1 -1
  26. package/jobs/permissions.js +2 -2
  27. package/jobs/program.js +1 -1
  28. package/jobs/prompt.d.ts +2 -1
  29. package/jobs/prompt.d.ts.map +1 -1
  30. package/jobs/prompt.js +20 -13
  31. package/jobs/schedule.d.ts +2 -1
  32. package/jobs/schedule.d.ts.map +1 -1
  33. package/jobs/schedule.js +15 -1
  34. package/jobs/slash-command.d.ts +6 -2
  35. package/jobs/slash-command.d.ts.map +1 -1
  36. package/jobs/slash-command.js +6 -5
  37. package/jobs/tool.d.ts +6 -4
  38. package/jobs/tool.d.ts.map +1 -1
  39. package/jobs/tool.js +3 -3
  40. package/mod.d.ts +63 -42
  41. package/mod.d.ts.map +1 -1
  42. package/mod.js +11 -1
  43. package/package.json +1 -1
  44. package/records.d.ts +14 -8
  45. package/records.d.ts.map +1 -1
  46. package/records.js +44 -31
  47. package/register.d.ts.map +1 -1
  48. package/register.js +1 -2
  49. package/runtime/announce.d.ts +1 -1
  50. package/runtime/announce.d.ts.map +1 -1
  51. package/runtime/announce.js +6 -7
  52. package/runtime/before.d.ts +2 -2
  53. package/runtime/before.d.ts.map +1 -1
  54. package/runtime/before.js +1 -1
  55. package/runtime/claude.d.ts +0 -1
  56. package/runtime/claude.d.ts.map +1 -1
  57. package/runtime/conversations.d.ts +4 -1
  58. package/runtime/conversations.d.ts.map +1 -1
  59. package/runtime/conversations.js +42 -14
  60. package/runtime/dependencies.d.ts.map +1 -1
  61. package/runtime/dependencies.js +2 -1
  62. package/runtime/engine.d.ts +5 -0
  63. package/runtime/engine.d.ts.map +1 -1
  64. package/runtime/grants.js +2 -2
  65. package/runtime/hooks.d.ts +1 -1
  66. package/runtime/hooks.d.ts.map +1 -1
  67. package/runtime/hotkeys.d.ts +15 -0
  68. package/runtime/hotkeys.d.ts.map +1 -0
  69. package/runtime/hotkeys.js +58 -0
  70. package/runtime/installer.d.ts +5 -2
  71. package/runtime/installer.d.ts.map +1 -1
  72. package/runtime/installer.js +23 -12
  73. package/runtime/lifecycle.d.ts.map +1 -1
  74. package/runtime/lifecycle.js +190 -141
  75. package/runtime/options.js +2 -2
  76. package/runtime/pages.d.ts +28 -0
  77. package/runtime/pages.d.ts.map +1 -0
  78. package/runtime/pages.js +98 -0
  79. package/runtime/parts.d.ts +14 -0
  80. package/runtime/parts.d.ts.map +1 -0
  81. package/runtime/parts.js +63 -0
  82. package/runtime/permission-check.d.ts +2 -2
  83. package/runtime/permission-check.d.ts.map +1 -1
  84. package/runtime/permission-check.js +12 -4
  85. package/runtime/programs.js +1 -1
  86. package/runtime/router.d.ts +2 -2
  87. package/runtime/router.d.ts.map +1 -1
  88. package/runtime/router.js +1 -1
  89. package/runtime/sessions.d.ts +10 -9
  90. package/runtime/sessions.d.ts.map +1 -1
  91. package/runtime/sessions.js +14 -5
  92. package/runtime/state.d.ts +2 -1
  93. package/runtime/state.d.ts.map +1 -1
  94. package/runtime/state.js +1 -0
  95. package/runtime/toaster.d.ts +1 -1
  96. package/runtime/toaster.d.ts.map +1 -1
  97. package/runtime/toaster.js +10 -4
  98. package/runtime/ui.d.ts +87 -5
  99. package/runtime/ui.d.ts.map +1 -1
  100. package/runtime/ui.js +372 -109
  101. package/runtime/watches.d.ts +9 -0
  102. package/runtime/watches.d.ts.map +1 -0
  103. package/runtime/watches.js +73 -0
  104. package/runtime/widgets.d.ts +48 -0
  105. package/runtime/widgets.d.ts.map +1 -0
  106. package/runtime/widgets.js +219 -0
  107. package/session/context/turn/message/message.d.ts.map +1 -1
  108. package/session/context/turn/message/message.js +7 -2
  109. package/session/session.d.ts +11 -5
  110. package/session/session.d.ts.map +1 -1
  111. package/src/hotkeys.ts +51 -0
  112. package/src/installer.ts +8 -4
  113. package/src/jobs/check.ts +4 -3
  114. package/src/jobs/context.ts +3 -2
  115. package/src/jobs/permissions.ts +4 -3
  116. package/src/jobs/program.ts +1 -1
  117. package/src/jobs/prompt.ts +23 -14
  118. package/src/jobs/schedule.ts +16 -2
  119. package/src/jobs/slash-command.ts +10 -7
  120. package/src/jobs/tool.ts +7 -5
  121. package/src/mod.ts +59 -47
  122. package/src/records.ts +50 -31
  123. package/src/register.ts +2 -3
  124. package/src/runtime/announce.ts +6 -7
  125. package/src/runtime/before.ts +4 -4
  126. package/src/runtime/claude.ts +0 -1
  127. package/src/runtime/conversations.ts +66 -20
  128. package/src/runtime/dependencies.ts +2 -1
  129. package/src/runtime/engine.ts +5 -0
  130. package/src/runtime/grants.ts +2 -2
  131. package/src/runtime/hooks.ts +1 -0
  132. package/src/runtime/hotkeys.ts +80 -0
  133. package/src/runtime/installer.ts +24 -12
  134. package/src/runtime/lifecycle.ts +189 -136
  135. package/src/runtime/options.ts +2 -2
  136. package/src/runtime/pages.ts +127 -0
  137. package/src/runtime/parts.ts +72 -0
  138. package/src/runtime/permission-check.ts +13 -5
  139. package/src/runtime/programs.ts +1 -1
  140. package/src/runtime/router.ts +3 -3
  141. package/src/runtime/sessions.ts +24 -15
  142. package/src/runtime/state.ts +3 -1
  143. package/src/runtime/toaster.ts +10 -4
  144. package/src/runtime/ui.ts +405 -115
  145. package/src/runtime/watches.ts +75 -0
  146. package/src/runtime/widgets.ts +287 -0
  147. package/src/session/context/turn/message/message.ts +7 -2
  148. package/src/session/session.ts +10 -6
  149. package/src/testing/fake-claude.ts +9 -6
  150. package/src/testing/fake-elements.ts +7 -4
  151. package/src/testing/fake-sessions.ts +6 -4
  152. package/src/testing.ts +203 -46
  153. package/src/ui/areas.ts +27 -0
  154. package/src/ui/elements.ts +35 -0
  155. package/src/ui/markdown.ts +18 -18
  156. package/src/ui/panes.ts +73 -0
  157. package/src/ui/styles.ts +53 -0
  158. package/src/utils/subscription.ts +1 -0
  159. package/testing/fake-claude.d.ts +0 -1
  160. package/testing/fake-claude.d.ts.map +1 -1
  161. package/testing/fake-claude.js +9 -5
  162. package/testing/fake-elements.js +7 -4
  163. package/testing/fake-sessions.d.ts +2 -1
  164. package/testing/fake-sessions.d.ts.map +1 -1
  165. package/testing/fake-sessions.js +1 -0
  166. package/testing.d.ts +21 -6
  167. package/testing.d.ts.map +1 -1
  168. package/testing.js +206 -45
  169. package/ui/areas.d.ts +29 -0
  170. package/ui/areas.d.ts.map +1 -0
  171. package/ui/areas.js +6 -0
  172. package/ui/elements.d.ts +3 -0
  173. package/ui/elements.d.ts.map +1 -1
  174. package/ui/elements.js +36 -0
  175. package/ui/markdown.d.ts +14 -14
  176. package/ui/markdown.d.ts.map +1 -1
  177. package/ui/markdown.js +11 -11
  178. package/ui/panes.d.ts +71 -0
  179. package/ui/panes.d.ts.map +1 -0
  180. package/ui/panes.js +16 -0
  181. package/ui/styles.d.ts +44 -0
  182. package/ui/styles.d.ts.map +1 -0
  183. package/ui/styles.js +24 -0
  184. package/utils/subscription.d.ts +4 -0
  185. package/utils/subscription.d.ts.map +1 -0
  186. package/jobs/status-line.d.ts +0 -6
  187. package/jobs/status-line.d.ts.map +0 -1
  188. package/jobs/status-line.js +0 -39
  189. package/src/jobs/status-line.ts +0 -44
  190. package/src/ui/define-pane.ts +0 -27
  191. package/src/ui/places.ts +0 -49
  192. package/src/utils/disposable.ts +0 -3
  193. package/ui/define-pane.d.ts +0 -15
  194. package/ui/define-pane.d.ts.map +0 -1
  195. package/ui/define-pane.js +0 -12
  196. package/ui/places.d.ts +0 -43
  197. package/ui/places.d.ts.map +0 -1
  198. package/ui/places.js +0 -31
  199. package/utils/disposable.d.ts +0 -4
  200. package/utils/disposable.d.ts.map +0 -1
  201. /package/utils/{disposable.js → subscription.js} +0 -0
@@ -1,6 +1,6 @@
1
1
  import type { PluginOptions } from 'claude-code'
2
2
  import { fitsOption, isUnset, type Option, type Options } from '../options.js'
3
- import { configFolders } from '../records.js'
3
+ import { configFolders, secretFix } from '../records.js'
4
4
  import { listed, messageOf } from '../utils/text.js'
5
5
  import type { Claude } from './claude.js'
6
6
  import type { Failure } from './toaster.js'
@@ -72,7 +72,7 @@ export function createOptions({
72
72
  for (const [key, value] of Object.entries(file)) {
73
73
  const option = declared[key]
74
74
  if (option === undefined) ignore(`sets ${key}, which ${name} does not declare. ${Object.keys(declared).length === 0 ? 'Remove it.' : `Remove it, or use one of: ${Object.keys(declared).join(', ')}.`}`)
75
- else if (option.type === 'secret') ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it, and set it in /config.`)
75
+ else if (option.type === 'secret') ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it. ${secretFix}`)
76
76
  else {
77
77
  const misfit = fitsOption(option, value)
78
78
  if (misfit === undefined) kept[key] = value
@@ -0,0 +1,127 @@
1
+ import type { RenderElement } from 'claude-code'
2
+ import { isObject } from '../records.js'
3
+ import { messageOf } from '../utils/text.js'
4
+ import type { Claude } from './claude.js'
5
+ import type { DrawnParts } from './parts.js'
6
+ import type { Router } from './router.js'
7
+ import { partMethods, type PageDeclaration, type PageRequest, type PartPress, type PartsUpdate, type ShownPages } from './ui.js'
8
+ import type { Widgets } from './widgets.js'
9
+
10
+ const askedWaitMs = 1000
11
+
12
+ export type ModPages = { readonly mod: string; readonly pages: readonly PageDeclaration[] }
13
+
14
+ export type PageApply = (mod: string, requests: readonly PageRequest[]) => Promise<void>
15
+
16
+ export type Pages = {
17
+ declared(): readonly ModPages[]
18
+ element(mod: string, page: string, params: unknown, columns: number, isFocused: boolean): RenderElement | undefined
19
+ press(mod: string, press: PartPress): void
20
+ update(mod: string, update: PartsUpdate): boolean
21
+ tell(mod: string, shown: ShownPages): void
22
+ apply(listener: PageApply): void
23
+ }
24
+
25
+ export function pagesOf(input: unknown): PageDeclaration[] {
26
+ const listed = isObject(input) && Array.isArray(input['pages']) ? (input['pages'] as unknown[]) : []
27
+ return listed.filter(isObject).flatMap((each) => (typeof each['id'] === 'string' && typeof each['title'] === 'string' && (each['place'] === 'sidebar' || each['place'] === 'settings') ? [{ id: each['id'], title: each['title'], place: each['place'] }] : []))
28
+ }
29
+
30
+ export function servePages({ claude, router, report, widgets, parts }: { readonly claude: Claude; readonly router: Router; readonly report: (id: string, message: string, error: unknown) => void; readonly widgets: Pick<Widgets, 'update'>; readonly parts: DrawnParts }): Pages {
31
+ const declared = new Map<string, readonly PageDeclaration[]>()
32
+ const asking = new Map<string, Promise<void>>()
33
+ const pressing = new Set<Promise<void>>()
34
+ let waiting: { readonly mod: string; readonly requests: readonly PageRequest[] }[] = []
35
+ let inputDepth = 0
36
+ let applyTo: PageApply = async () => undefined
37
+
38
+ const run = (mod: string, requests: readonly PageRequest[]) =>
39
+ applyTo(mod, requests).catch((error: unknown) => report(`pages:${mod}`, `Could not show the page ${mod} asked for`, error))
40
+
41
+ const takeWaiting = async () => {
42
+ const taken = waiting
43
+ waiting = []
44
+ for (const { mod, requests } of taken) await run(mod, requests)
45
+ }
46
+
47
+ const settle = async () => {
48
+ while (pressing.size > 0) await Promise.allSettled(pressing)
49
+ await takeWaiting()
50
+ }
51
+
52
+ const wrapped = async <Answer>(next: () => Promise<Answer>): Promise<Answer> => {
53
+ inputDepth += 1
54
+ try {
55
+ const answer = await next()
56
+ await settle()
57
+ return answer
58
+ } finally {
59
+ inputDepth -= 1
60
+ }
61
+ }
62
+
63
+ router.add('command.run', (e, next) => wrapped(async () => (waiting.length > 0 ? {} : next(e))))
64
+ router.add('ui.press', (e, next) => wrapped(() => next(e)))
65
+
66
+ const ask = (mod: string) => {
67
+ const pending =
68
+ asking.get(mod) ??
69
+ claude.cmod
70
+ .call({ to: mod, method: partMethods.list, input: null })
71
+ .then(
72
+ (parts) => void declared.set(mod, pagesOf(parts)),
73
+ (error: unknown) => claude.ui.log(`cmod: ${mod} did not list its pages: ${messageOf(error)}`, { to: 'debug' }),
74
+ )
75
+ .finally(() => asking.delete(mod))
76
+ asking.set(mod, pending)
77
+ return pending
78
+ }
79
+
80
+ const update = (mod: string, { parts: listing, redraw, requests, isAsked }: PartsUpdate) => {
81
+ if (listing !== undefined) declared.set(mod, pagesOf(listing))
82
+ if (listing !== undefined || redraw === true) widgets.update(mod, { ...(listing === undefined ? {} : { parts: listing }), ...(redraw === undefined ? {} : { redraw }) })
83
+ if (requests === undefined || requests.length === 0) return false
84
+ if (isAsked !== true) return (void run(mod, requests), false)
85
+ waiting.push({ mod, requests })
86
+ if (inputDepth > 0) return true
87
+ claude.clock.after(askedWaitMs, () => void takeWaiting())
88
+ return false
89
+ }
90
+
91
+ const press = (mod: string, input: PartPress) => {
92
+ const pending = claude.cmod
93
+ .call({ to: mod, method: partMethods.press, input })
94
+ .then(
95
+ (answer) => void update(mod, (isObject(answer) ? answer : {}) as PartsUpdate),
96
+ (error: unknown) => {
97
+ parts.forget(mod)
98
+ claude.ui.invalidate('ui.render')
99
+ report(`press:${mod}`, `${mod} did not take the press`, error)
100
+ },
101
+ )
102
+ .finally(() => pressing.delete(pending))
103
+ pressing.add(pending)
104
+ if (inputDepth === 0) void pending.then(takeWaiting)
105
+ }
106
+
107
+ const cacheKey = (page: string, params: unknown, columns: number, isFocused: boolean) => `${page}\u0000${JSON.stringify(params ?? null)}\u0000${columns}\u0000${isFocused}`
108
+
109
+ return {
110
+ declared: () => [...declared].map(([mod, pages]) => ({ mod, pages })),
111
+ element(mod, page, params, columns, isFocused) {
112
+ if (!declared.has(mod)) void ask(mod)
113
+ const cached = cacheKey(page, params, columns, isFocused)
114
+ const name = `The ${page} page of ${mod}`
115
+ void parts.fill(mod, cached, { page, params: params ?? null, columns, isFocused }, name)?.then(() => claude.ui.invalidate('ui.render'))
116
+ return parts.element(mod, cached, `${mod}/${page}:`, name, `page:${mod}/${page}`)
117
+ },
118
+ press,
119
+ update,
120
+ tell(mod, shown) {
121
+ void claude.cmod.call({ to: mod, method: partMethods.shown, input: shown }).catch((error: unknown) => claude.ui.log(`cmod: ${mod} did not hear which of its pages show: ${messageOf(error)}`, { to: 'debug' }))
122
+ },
123
+ apply(listener) {
124
+ applyTo = listener
125
+ },
126
+ }
127
+ }
@@ -0,0 +1,72 @@
1
+ import type { RenderElement } from 'claude-code'
2
+ import { elementOf } from '../ui/elements.js'
3
+ import { messageOf } from '../utils/text.js'
4
+ import type { Claude } from './claude.js'
5
+ import { partMethods, type DrawnPart, type PartPress } from './ui.js'
6
+
7
+ export type DrawnParts = {
8
+ fill(mod: string, cached: string, input: Readonly<Record<string, unknown>>, name: string): Promise<void> | undefined
9
+ element(mod: string, cached: string, keyPrefix: string, name: string, reportId: string): RenderElement | undefined
10
+ forget(mod: string): void
11
+ }
12
+
13
+ type Drawn = { readonly data: DrawnPart | null; readonly generation: number }
14
+
15
+ const keptDrawings = 200
16
+
17
+ export function drawnParts({ claude, report, press }: { readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly press: (mod: string, press: PartPress) => void }): DrawnParts {
18
+ const drawn = new Map<string, Drawn>()
19
+ const drawing = new Map<string, Promise<void>>()
20
+ const generations = new Map<string, number>()
21
+ const keyOf = (mod: string, cached: string) => `${mod}\u0000${cached}`
22
+ const generationOf = (mod: string) => generations.get(mod) ?? 0
23
+
24
+ return {
25
+ fill(mod, cached, input, name) {
26
+ const key = keyOf(mod, cached)
27
+ const generation = generationOf(mod)
28
+ if (drawn.get(key)?.generation === generation) return undefined
29
+ const keep = (data: DrawnPart | null) => {
30
+ drawn.delete(key)
31
+ drawn.set(key, { data, generation })
32
+ const [oldest] = drawn.keys()
33
+ if (drawn.size > keptDrawings && oldest !== undefined) drawn.delete(oldest)
34
+ if (generationOf(mod) !== generation) claude.ui.invalidate('ui.render')
35
+ }
36
+ const pending =
37
+ drawing.get(key) ??
38
+ claude.cmod
39
+ .call({ to: mod, method: partMethods.draw, input })
40
+ .then(
41
+ (data) => keep((data ?? null) as DrawnPart | null),
42
+ (error: unknown) => {
43
+ claude.ui.log(`cmod: ${name} drew nothing: ${messageOf(error)}`, { to: 'debug' })
44
+ keep(null)
45
+ },
46
+ )
47
+ .finally(() => drawing.delete(key))
48
+ drawing.set(key, pending)
49
+ return pending
50
+ },
51
+ element(mod, cached, keyPrefix, name, reportId) {
52
+ const key = keyOf(mod, cached)
53
+ const entry = drawn.get(key)
54
+ if (entry === undefined) return undefined
55
+ drawn.delete(key)
56
+ drawn.set(key, entry)
57
+ const { data } = entry
58
+ if (data === null || data.tree === null) return undefined
59
+ try {
60
+ return elementOf(data.tree, keyPrefix, (handler, args) => press(mod, { draw: data.draw, handler, args })) as RenderElement
61
+ } catch (error) {
62
+ report(reportId, `${name} could not draw`, error)
63
+ return undefined
64
+ }
65
+ },
66
+ forget(mod) {
67
+ const superseded = generationOf(mod)
68
+ generations.set(mod, superseded + 1)
69
+ for (const [key, { generation }] of drawn) if (key.startsWith(`${mod}\u0000`) && generation < superseded) drawn.delete(key)
70
+ },
71
+ }
72
+ }
@@ -1,4 +1,4 @@
1
- import type { Disposable } from '../mod.js'
1
+ import type { Subscription } from '../utils/subscription.js'
2
2
  import type { HookFields } from './hooks.js'
3
3
  import { defined, processPayloadOf, recordOf, type HookOn, type RouteOn, type ToolCheck } from './sessions.js'
4
4
 
@@ -10,12 +10,14 @@ export type PermissionUse = {
10
10
  readonly current: () => { readonly id: string; readonly cwd: string }
11
11
  }
12
12
 
13
- export type PermissionRoutes = (method: string, use: PermissionUse) => Disposable
13
+ export type PermissionRoutes = (method: string, use: PermissionUse) => Subscription
14
14
 
15
15
  type Verdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?: string; readonly rule?: string }
16
16
 
17
17
  const verdictName = (decision: Verdict['decision']) => (decision === 'deny' ? 'refuse' : decision)
18
18
 
19
+ type BuildFlags = { readonly cmodWithoutApprove?: true }
20
+
19
21
  export const permissionRoutes: PermissionRoutes = (method, { raise, refusalFor, hookOn, routeOn, current }) => {
20
22
  const isBefore = method.startsWith('before')
21
23
  if (method.endsWith('PermissionCheck')) {
@@ -39,8 +41,11 @@ export const permissionRoutes: PermissionRoutes = (method, { raise, refusalFor,
39
41
  const answer = await raise(method, payload)
40
42
  if (answer === undefined || below.decision === 'deny') return below
41
43
  if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
42
- if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
43
- return answer['allow'] === true ? { decision: 'allow' } : below
44
+ if (!(globalThis as BuildFlags).cmodWithoutApprove) {
45
+ if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
46
+ if (answer['allow'] === true) return { decision: 'allow' }
47
+ }
48
+ return below
44
49
  },
45
50
  isBefore ? failed : undefined,
46
51
  )
@@ -57,7 +62,10 @@ export const permissionRoutes: PermissionRoutes = (method, { raise, refusalFor,
57
62
  const answer = await raise(method, payload)
58
63
  if (answer === undefined) return undefined
59
64
  if (typeof answer['refuse'] === 'string') return refused(answer['refuse'], answer['interrupt'])
60
- return answer['allow'] === true ? { decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) } : undefined
65
+ if (!(globalThis as BuildFlags).cmodWithoutApprove) {
66
+ if (answer['allow'] === true) return { decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) }
67
+ }
68
+ return undefined
61
69
  },
62
70
  isBefore
63
71
  ? (input, error) => {
@@ -47,7 +47,7 @@ export function locatingPrograms(claude: Claude): Programs {
47
47
  let names: ReadonlySet<string> = new Set()
48
48
  const located = (argv: readonly string[]): readonly string[] => {
49
49
  const [name, ...rest] = argv
50
- return folder !== undefined && name !== undefined && names.has(name) ? [`${folder}/${name}`, ...rest] : argv
50
+ return folder !== undefined && name !== undefined && (name === 'cmod' || names.has(name)) ? [`${folder}/${name}`, ...rest] : argv
51
51
  }
52
52
  return {
53
53
  claude: {
@@ -1,5 +1,5 @@
1
1
  import type { Args, EventResult, Frozen } from 'claude-code'
2
- import type { Disposable } from '../mod.js'
2
+ import type { Subscription } from '../utils/subscription.js'
3
3
  import type { RoutedEvent } from './hooks.js'
4
4
 
5
5
  export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<EventResult<N>>
@@ -9,7 +9,7 @@ export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: Route
9
9
  export type RoutedFailure<N extends RoutedEvent> = (e: Frozen<Args<N>>, error: Error, next: RouterNext<N>) => EventResult<N> | undefined | Promise<EventResult<N> | undefined>
10
10
 
11
11
  export type Router = {
12
- add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>, failed?: RoutedFailure<N>): Disposable
12
+ add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>, failed?: RoutedFailure<N>): Subscription
13
13
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
14
14
  dispatchFailure<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, error: Error, next: RouterNext<N>): Promise<EventResult<N>>
15
15
  }
@@ -24,7 +24,7 @@ export function createRouter(): Router {
24
24
  const route = { hook, ...(failed === undefined ? {} : { failed }) } as Route
25
25
  routes.set(event, [...(routes.get(event) ?? []), route])
26
26
  return {
27
- dispose() {
27
+ remove() {
28
28
  const chain = (routes.get(event) ?? []).filter((each) => each !== route)
29
29
  if (chain.length === 0) routes.delete(event)
30
30
  else routes.set(event, chain)
@@ -1,5 +1,5 @@
1
1
  import type { Args, CommandRunResult, SessionCompactResult } from 'claude-code'
2
- import type { Disposable } from '../mod.js'
2
+ import type { Subscription } from '../utils/subscription.js'
3
3
  import { ContextEnd, ContextStart, type Context } from '../session/context/context.js'
4
4
  import { Message, noFiles } from '../session/context/turn/message/message.js'
5
5
  import { modNote, Note } from '../session/context/turn/message/note.js'
@@ -16,11 +16,12 @@ import { beforeAnswerKeys, foldAnswers, foldBefore, methodOf, takenAnswer, type
16
16
  import type { Claude } from './claude.js'
17
17
  import { cmodPluginName } from './dependencies.js'
18
18
  import { messageOf } from '../utils/text.js'
19
+ import { grantFix, permissionWords } from '../records.js'
19
20
  import { listenerOf, PassedTimeout, withinTime, type ListenOptions, type ListenTime } from './deadline.js'
20
21
  import { callFiles, type HomeFolders, type HookFields } from './hooks.js'
21
22
  import type { PermissionRoutes } from './permission-check.js'
22
23
 
23
- export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined; readonly onFailure?: 'refuse'; readonly scope?: Scope }
24
+ export type Listened = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined; readonly onFailure?: 'refuse'; readonly scope?: Scope }
24
25
 
25
26
  export const everySession = '*'
26
27
 
@@ -33,7 +34,6 @@ export const conversationMethods = {
33
34
  tasks: 'cmod:tasks',
34
35
  goal: 'cmod:goal',
35
36
  updateGoal: 'cmod:updateGoal',
36
- usage: 'cmod:usage',
37
37
  subscribe: 'cmod:subscribe',
38
38
  watchBefore: 'cmod:watchBefore',
39
39
  act: 'cmod:act',
@@ -69,7 +69,8 @@ export type SessionsClient = {
69
69
  answerBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<unknown>
70
70
  failedBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean, error: Error): { readonly refuse: string } | undefined
71
71
  raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
72
- listen(method: string, args: readonly unknown[]): Disposable
72
+ listen(method: string, args: readonly unknown[]): Subscription
73
+ turn(data: TurnData): Turn
73
74
  /** An empty title keeps the title the session already has. */
74
75
  follow(current: Omit<SessionData, 'isRunning' | 'lastAt'>): void
75
76
  }
@@ -138,7 +139,7 @@ export function withoutPermissionCheck(name: string, hearer: string): string {
138
139
  return `${name}: ${hearer} hears Claude Code's permission check, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.`
139
140
  }
140
141
 
141
- export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>, failed?: (input: unknown, error: Error) => HookFields | undefined) => Disposable
142
+ export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>, failed?: (input: unknown, error: Error) => HookFields | undefined) => Subscription
142
143
 
143
144
  export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn, permissionRoutes }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean; readonly hookOn?: HookOn; readonly routeOn?: RouteOn; readonly permissionRoutes?: () => PermissionRoutes | undefined }): SessionsClient {
144
145
  const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
@@ -156,7 +157,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
156
157
  queued.add(key)
157
158
  void Promise.resolve().then(async () => {
158
159
  queued.delete(key)
159
- const heard = (from: Map<string, Set<Listening>>): Subscription[] =>
160
+ const heard = (from: Map<string, Set<Listening>>): Listened[] =>
160
161
  [...(from.get(key) ?? [])].map(({ event, matcher, options, scope }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher), ...(options.onFailure === 'refuse' ? { onFailure: 'refuse' as const, scope } : {}) }))
161
162
  const tracksTasks = [...(listening.get(key) ?? []), ...(deciding.get(key) ?? [])].some(({ scope }) => scope.task !== undefined || scope.isGoal === true)
162
163
  const subscriptions = [...heard(listening), ...(tracksTasks ? [{ event: TaskStart }] : [])]
@@ -182,7 +183,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
182
183
  if (change.type === TaskStart) parents.get(session)?.set(change.task.id, change.task.parent)
183
184
  }
184
185
 
185
- const add = (into: Map<string, Set<Listening>>, scope: Scope, event: SessionEvent, method: string, args: readonly unknown[], onChange: (key: string) => void): Disposable => {
186
+ const add = (into: Map<string, Set<Listening>>, scope: Scope, event: SessionEvent, method: string, args: readonly unknown[], onChange: (key: string) => void): Subscription => {
186
187
  const key = keyOf(scope)
187
188
  const entry: Listening = { scope, event, method, ...listenerOf(name, method, args) }
188
189
  const set = into.get(key) ?? new Set()
@@ -191,7 +192,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
191
192
  if (scope.task !== undefined || scope.isGoal === true) void learnTasks(scope.session ?? current.id).catch((error: unknown) => report(`tasks:${key}`, 'Could not read the tasks it listens under', error))
192
193
  onChange(key)
193
194
  return {
194
- dispose() {
195
+ remove() {
195
196
  for (const [at, held] of into) {
196
197
  if (!held.delete(entry)) continue
197
198
  if (held.size === 0) into.delete(at)
@@ -224,7 +225,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
224
225
  const payload = await payloadFor(session, latest)
225
226
  const answer = await invoke(entry.method, entry.options, async (time) => takenAnswer(entry.method, beforeAnswerKeys[event], await entry.listener(payload as never, time)), beforeAnswerKeys[event])
226
227
  if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
227
- throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
228
+ throw new Error(`Its ${entry.method} answer changes nothing until you grant ${name} "${permissionWords(beforePermission(event))}". ${grantFix}`)
228
229
  })
229
230
  const answer = await foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
230
231
  return answer?.note === undefined ? answer : { ...answer, note: modNote(name, answer.note) }
@@ -534,7 +535,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
534
535
  return { ...compacted, messages: [...compacted.messages, { role: 'user', text: modNote(name, note), toolUses: [] }] }
535
536
  }
536
537
 
537
- const listen = (method: string, args: readonly unknown[], isEveryAgent = false): Disposable => {
538
+ const listen = (method: string, args: readonly unknown[], isEveryAgent = false): Subscription => {
538
539
  const heard = listenerOf(name, method, args)
539
540
  const entry = { ...heard, options: method === 'beforePermissionRequest' ? { ...heard.options, onFailure: 'refuse' } : heard.options, isEveryAgent } as ProcessListener
540
541
  const held = processListeners.get(method) ?? new Set()
@@ -543,7 +544,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
543
544
  processListeners.set(method, held)
544
545
  }
545
546
  held.add(entry)
546
- return { dispose: () => void held.delete(entry) }
547
+ return { remove: () => void held.delete(entry) }
547
548
  }
548
549
 
549
550
  const processMethods = (): ProcessOnMethods & ProcessBeforeMethods =>
@@ -552,7 +553,14 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
552
553
  ) as ProcessOnMethods & ProcessBeforeMethods
553
554
 
554
555
  const here = sessionOf(() => current)
555
- const currentSession = Object.assign(here, processMethods(), { goal: Object.assign(here.goal, { beforeSessionStart: (...args: unknown[]) => listen('beforeSessionStart', args, true) }) })
556
+ const currentSession = Object.assign(here, processMethods(), {
557
+ goal: Object.assign(here.goal, { beforeSessionStart: (...args: unknown[]) => listen('beforeSessionStart', args, true) }),
558
+ async scrollTo(message: Message, options?: { readonly block?: 'start' | 'center' | 'end' | 'nearest' }) {
559
+ const { deny } = await claude.ui.scroll({ to: { requestId: message.id }, ...(options?.block === undefined ? {} : { block: options.block }) })
560
+ if (deny !== undefined) throw new Error(`${name}: Claude Code did not scroll to the message: ${deny}`)
561
+ },
562
+ caption: (toolCall: Message & { readonly type: typeof ToolCall }, text: string | undefined) => claude.ui.notice(toolCall.callId, text),
563
+ })
556
564
  const every = eventMethods(() => ({}), false)
557
565
 
558
566
  const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (mayDecide(session) ? combined(session, event, change, isInProject) : undefined)
@@ -596,6 +604,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
596
604
  failedBefore,
597
605
  raise,
598
606
  listen,
607
+ turn: (data) => turnOf({ session: current.id }, data),
599
608
  follow(next) {
600
609
  const moved = next.id !== current.id && current.id !== ''
601
610
  const previous = current.id
@@ -618,9 +627,9 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
618
627
  export type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
619
628
 
620
629
  export type RouteOn = {
621
- (event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>, failed?: (e: ToolCheck, error: Error, next: (e: ToolCheck) => Promise<unknown>) => Promise<{ readonly decision: 'deny'; readonly reason: string } | undefined>): Disposable
622
- (event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>, failed?: (e: Args<'command.run'>, error: Error) => Promise<CommandRunResult | undefined>): Disposable
623
- (event: 'session.compact', hook: (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>) => Promise<SessionCompactResult>): Disposable
630
+ (event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>, failed?: (e: ToolCheck, error: Error, next: (e: ToolCheck) => Promise<unknown>) => Promise<{ readonly decision: 'deny'; readonly reason: string } | undefined>): Subscription
631
+ (event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>, failed?: (e: Args<'command.run'>, error: Error) => Promise<CommandRunResult | undefined>): Subscription
632
+ (event: 'session.compact', hook: (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>) => Promise<SessionCompactResult>): Subscription
624
633
  }
625
634
 
626
635
  export const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
@@ -1,4 +1,5 @@
1
1
  import type { ClassicHookInputs } from 'claude-code'
2
+ import type { Subscription } from '../utils/subscription.js'
2
3
  import type { Claude } from './claude.js'
3
4
  import type { Failure } from './toaster.js'
4
5
 
@@ -34,10 +35,11 @@ const keptProjects = 20
34
35
 
35
36
  const listeners = new WeakMap<object, Set<() => void>>()
36
37
 
37
- export function onStateChange(state: object, listener: () => void): void {
38
+ export function onStateChange(state: object, listener: () => void): Subscription {
38
39
  const own = listeners.get(state) ?? new Set()
39
40
  own.add(listener)
40
41
  listeners.set(state, own)
42
+ return { remove: () => void own.delete(listener) }
41
43
  }
42
44
 
43
45
  export async function sessionsWith(claude: Pick<Claude, 'store'>, name: string, key: string, value: unknown): Promise<string[]> {
@@ -1,7 +1,7 @@
1
1
  import type { ElementTable, RenderElement, RenderPropsOf, Timer } from 'claude-code'
2
2
  import type { Toast, ToastId, ToastOptions, ToastType } from '../mod.js'
3
3
  import { Box, Button, drawWith, Link, Text } from '../ui/elements.js'
4
- import { coreVersion, isObject } from '../records.js'
4
+ import { coreVersion, grantFix, installFix, isObject, secretFix } from '../records.js'
5
5
  import { firstLine, messageOf } from '../utils/text.js'
6
6
  import type { Claude } from './claude.js'
7
7
 
@@ -54,7 +54,7 @@ const icons: Record<Exclude<ToastType, 'loading'>, { readonly glyph: string; rea
54
54
  error: { glyph: '✗', color: 'error' },
55
55
  }
56
56
 
57
- export function createToaster(claude: Claude, name: string, version: () => string | undefined): Toaster {
57
+ export function createToaster(claude: Claude, name: string, version: () => string | undefined, openPermissions: () => unknown): Toaster {
58
58
  const entries = new Map<ToastId, Entry>()
59
59
  const hidden = new Map<string, string>()
60
60
  void claude.store.get(hiddenKey).then(
@@ -176,7 +176,7 @@ export function createToaster(claude: Claude, name: string, version: () => strin
176
176
  .then(run)
177
177
  .catch((error: unknown) => fail({ id: `press:${entry.id}`, message: `${entry.message}: the press failed`, error }))
178
178
 
179
- const fail = ({ id, message, error, description = error === undefined ? undefined : describe(error), retry }: Failure): ToastId => {
179
+ const fail = ({ id, message, error, description = error === undefined || describe(error) === message ? undefined : describe(error), retry }: Failure): ToastId => {
180
180
  const hides = `${version() ?? ''}\n${message}\n${error === undefined ? '' : describe(error)}`
181
181
  if (hidden.get(id) === hides) return id
182
182
  hidden.delete(id)
@@ -214,7 +214,13 @@ export function createToaster(claude: Claude, name: string, version: () => strin
214
214
  }
215
215
  }
216
216
  }
217
- const actions = [...(retry === undefined ? [] : [{ label: 'Retry', onClick: tryAgain }]), { label: 'Report', onClick: startReporting }]
217
+ const text = `${message}\n${description ?? ''}`
218
+ const fixes = [
219
+ { sentence: grantFix, label: 'Settings', onClick: openPermissions },
220
+ { sentence: installFix, label: 'Plugins', onClick: () => claude.command.run({ command: 'plugin' }) },
221
+ { sentence: secretFix, label: 'Open /config', onClick: () => claude.command.run({ command: 'config' }) },
222
+ ].filter(({ sentence }) => text.includes(sentence))
223
+ const actions = [...fixes, ...(retry === undefined ? [] : [{ label: 'Retry', onClick: tryAgain }]), { label: 'Report', onClick: startReporting }]
218
224
  return put('error', message, { id, ...(description === undefined ? {} : { description }), duration: Infinity }, actions, hides)
219
225
  }
220
226