@cmodjs/core 0.2.3 → 1.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 (273) hide show
  1. package/docs/commands.md +58 -10
  2. package/docs/dependencies.md +12 -12
  3. package/docs/hooks.md +41 -28
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +9 -7
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +200 -174
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +248 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +66 -23
  13. package/docs/ui.md +108 -76
  14. package/jobs/check.d.ts.map +1 -1
  15. package/jobs/check.js +5 -2
  16. package/jobs/context.d.ts +7 -8
  17. package/jobs/context.d.ts.map +1 -1
  18. package/jobs/context.js +23 -73
  19. package/jobs/permissions/decide-permission.d.ts +5 -3
  20. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  21. package/jobs/permissions/decide-permission.js +6 -4
  22. package/jobs/permissions/find-project-scope.d.ts +1 -1
  23. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  24. package/jobs/permissions/find-project-scope.js +8 -11
  25. package/jobs/permissions/match-target.d.ts +2 -2
  26. package/jobs/permissions/match-target.d.ts.map +1 -1
  27. package/jobs/permissions.d.ts +1 -1
  28. package/jobs/permissions.d.ts.map +1 -1
  29. package/jobs/permissions.js +6 -4
  30. package/jobs/program.d.ts.map +1 -1
  31. package/jobs/program.js +8 -3
  32. package/jobs/prompt.d.ts +4 -2
  33. package/jobs/prompt.d.ts.map +1 -1
  34. package/jobs/prompt.js +16 -15
  35. package/jobs/schedule.d.ts +2 -2
  36. package/jobs/schedule.d.ts.map +1 -1
  37. package/jobs/schedule.js +9 -9
  38. package/jobs/slash-command.d.ts +4 -4
  39. package/jobs/slash-command.d.ts.map +1 -1
  40. package/jobs/slash-command.js +14 -9
  41. package/jobs/status-line.d.ts +1 -11
  42. package/jobs/status-line.d.ts.map +1 -1
  43. package/jobs/status-line.js +8 -12
  44. package/jobs/tool.d.ts +2 -2
  45. package/jobs/tool.d.ts.map +1 -1
  46. package/jobs/tool.js +15 -9
  47. package/mod.d.ts +154 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +94 -3
  50. package/options.d.ts +2 -2
  51. package/options.js +19 -19
  52. package/package.json +3 -3
  53. package/records.d.ts +10 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +28 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +18 -6
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +66 -0
  61. package/runtime/bound.d.ts +4 -0
  62. package/runtime/bound.d.ts.map +1 -0
  63. package/runtime/bound.js +9 -0
  64. package/runtime/claude.d.ts +3 -3
  65. package/runtime/claude.d.ts.map +1 -1
  66. package/runtime/conversations.d.ts +16 -0
  67. package/runtime/conversations.d.ts.map +1 -0
  68. package/runtime/conversations.js +291 -0
  69. package/runtime/deadline.d.ts +0 -1
  70. package/runtime/deadline.d.ts.map +1 -1
  71. package/runtime/deadline.js +1 -1
  72. package/runtime/dependencies.d.ts +2 -0
  73. package/runtime/dependencies.d.ts.map +1 -1
  74. package/runtime/dependencies.js +9 -0
  75. package/runtime/engine.d.ts +17 -0
  76. package/runtime/engine.d.ts.map +1 -0
  77. package/runtime/engine.js +10 -0
  78. package/runtime/grants.d.ts +4 -0
  79. package/runtime/grants.d.ts.map +1 -1
  80. package/runtime/grants.js +63 -33
  81. package/runtime/hooks.d.ts +5 -4
  82. package/runtime/hooks.d.ts.map +1 -1
  83. package/runtime/hooks.js +10 -5
  84. package/runtime/installer.d.ts.map +1 -1
  85. package/runtime/installer.js +11 -9
  86. package/runtime/lifecycle.d.ts +3 -1
  87. package/runtime/lifecycle.d.ts.map +1 -1
  88. package/runtime/lifecycle.js +317 -186
  89. package/runtime/metadata.d.ts +3 -1
  90. package/runtime/metadata.d.ts.map +1 -1
  91. package/runtime/metadata.js +21 -11
  92. package/runtime/options.d.ts +3 -1
  93. package/runtime/options.d.ts.map +1 -1
  94. package/runtime/options.js +4 -4
  95. package/runtime/programs.d.ts +8 -0
  96. package/runtime/programs.d.ts.map +1 -1
  97. package/runtime/programs.js +39 -1
  98. package/runtime/router.d.ts +2 -1
  99. package/runtime/router.d.ts.map +1 -1
  100. package/runtime/router.js +11 -3
  101. package/runtime/sessions.d.ts +36 -0
  102. package/runtime/sessions.d.ts.map +1 -0
  103. package/runtime/sessions.js +235 -0
  104. package/runtime/state.d.ts +6 -2
  105. package/runtime/state.d.ts.map +1 -1
  106. package/runtime/state.js +46 -13
  107. package/runtime/toaster.d.ts +31 -0
  108. package/runtime/toaster.d.ts.map +1 -0
  109. package/runtime/toaster.js +176 -0
  110. package/runtime/ui.d.ts +6 -17
  111. package/runtime/ui.d.ts.map +1 -1
  112. package/runtime/ui.js +36 -112
  113. package/session/context/context.d.ts +10 -0
  114. package/session/context/context.d.ts.map +1 -0
  115. package/session/context/context.js +2 -0
  116. package/session/context/turn/message/document.d.ts +7 -0
  117. package/session/context/turn/message/document.d.ts.map +1 -0
  118. package/session/context/turn/message/document.js +1 -0
  119. package/session/context/turn/message/message.d.ts +54 -0
  120. package/session/context/turn/message/message.d.ts.map +1 -0
  121. package/session/context/turn/message/message.js +137 -0
  122. package/session/context/turn/message/note.d.ts +8 -0
  123. package/session/context/turn/message/note.d.ts.map +1 -0
  124. package/session/context/turn/message/note.js +1 -0
  125. package/session/context/turn/message/notice.d.ts +6 -0
  126. package/session/context/turn/message/notice.d.ts.map +1 -0
  127. package/session/context/turn/message/notice.js +1 -0
  128. package/session/context/turn/message/prompt.d.ts +8 -0
  129. package/session/context/turn/message/prompt.d.ts.map +1 -0
  130. package/session/context/turn/message/prompt.js +1 -0
  131. package/session/context/turn/message/reply.d.ts +6 -0
  132. package/session/context/turn/message/reply.d.ts.map +1 -0
  133. package/session/context/turn/message/reply.js +1 -0
  134. package/session/context/turn/message/sender.d.ts +14 -0
  135. package/session/context/turn/message/sender.d.ts.map +1 -0
  136. package/session/context/turn/message/sender.js +30 -0
  137. package/session/context/turn/message/skill.d.ts +7 -0
  138. package/session/context/turn/message/skill.d.ts.map +1 -0
  139. package/session/context/turn/message/skill.js +1 -0
  140. package/session/context/turn/message/tool-call.d.ts +9 -0
  141. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  142. package/session/context/turn/message/tool-call.js +1 -0
  143. package/session/context/turn/message/tool-result.d.ts +8 -0
  144. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  145. package/session/context/turn/message/tool-result.js +1 -0
  146. package/session/context/turn/turn.d.ts +12 -0
  147. package/session/context/turn/turn.d.ts.map +1 -0
  148. package/session/context/turn/turn.js +2 -0
  149. package/session/conversation.d.ts +64 -0
  150. package/session/conversation.d.ts.map +1 -0
  151. package/session/conversation.js +123 -0
  152. package/session/goal/goal.d.ts +10 -0
  153. package/session/goal/goal.d.ts.map +1 -0
  154. package/session/goal/goal.js +1 -0
  155. package/session/goal/task.d.ts +16 -0
  156. package/session/goal/task.d.ts.map +1 -0
  157. package/session/goal/task.js +3 -0
  158. package/session/session.d.ts +99 -0
  159. package/session/session.d.ts.map +1 -0
  160. package/session/session.js +1 -0
  161. package/src/jobs/check.ts +7 -4
  162. package/src/jobs/context.ts +24 -82
  163. package/src/jobs/permissions/decide-permission.ts +10 -8
  164. package/src/jobs/permissions/find-project-scope.ts +8 -14
  165. package/src/jobs/permissions/match-target.ts +3 -3
  166. package/src/jobs/permissions.ts +8 -6
  167. package/src/jobs/program.ts +7 -3
  168. package/src/jobs/prompt.ts +21 -24
  169. package/src/jobs/schedule.ts +14 -14
  170. package/src/jobs/slash-command.ts +20 -14
  171. package/src/jobs/status-line.ts +9 -21
  172. package/src/jobs/tool.ts +17 -11
  173. package/src/mod.ts +209 -97
  174. package/src/options.ts +22 -22
  175. package/src/records.ts +35 -23
  176. package/src/register.ts +18 -6
  177. package/src/runtime/announce.ts +74 -0
  178. package/src/runtime/bound.ts +12 -0
  179. package/src/runtime/claude.ts +5 -3
  180. package/src/runtime/conversations.ts +302 -0
  181. package/src/runtime/deadline.ts +2 -2
  182. package/src/runtime/dependencies.ts +12 -0
  183. package/src/runtime/engine.ts +22 -0
  184. package/src/runtime/grants.ts +66 -31
  185. package/src/runtime/hooks.ts +19 -10
  186. package/src/runtime/installer.ts +10 -9
  187. package/src/runtime/lifecycle.ts +313 -181
  188. package/src/runtime/metadata.ts +33 -11
  189. package/src/runtime/options.ts +19 -4
  190. package/src/runtime/programs.ts +40 -1
  191. package/src/runtime/router.ts +11 -4
  192. package/src/runtime/sessions.ts +242 -0
  193. package/src/runtime/state.ts +50 -14
  194. package/src/runtime/toaster.ts +218 -0
  195. package/src/runtime/ui.ts +42 -139
  196. package/src/session/context/context.ts +12 -0
  197. package/src/session/context/turn/message/document.ts +8 -0
  198. package/src/session/context/turn/message/message.ts +165 -0
  199. package/src/session/context/turn/message/note.ts +9 -0
  200. package/src/session/context/turn/message/notice.ts +7 -0
  201. package/src/session/context/turn/message/prompt.ts +9 -0
  202. package/src/session/context/turn/message/reply.ts +7 -0
  203. package/src/session/context/turn/message/sender.ts +38 -0
  204. package/src/session/context/turn/message/skill.ts +8 -0
  205. package/src/session/context/turn/message/tool-call.ts +10 -0
  206. package/src/session/context/turn/message/tool-result.ts +9 -0
  207. package/src/session/context/turn/turn.ts +14 -0
  208. package/src/session/conversation.ts +169 -0
  209. package/src/session/goal/goal.ts +9 -0
  210. package/src/session/goal/task.ts +19 -0
  211. package/src/session/session.ts +94 -0
  212. package/src/testing/fake-claude.ts +23 -18
  213. package/src/testing/fake-elements.ts +3 -2
  214. package/src/testing/fake-files.ts +1 -1
  215. package/src/testing/fake-sessions.ts +203 -0
  216. package/src/testing.ts +112 -33
  217. package/src/ui/components.ts +3 -3
  218. package/src/ui/elements.ts +22 -8
  219. package/src/ui/markdown.ts +22 -26
  220. package/src/ui/places.ts +49 -0
  221. package/src/utils/call-effects.ts +3 -3
  222. package/src/utils/changelog.ts +32 -0
  223. package/src/utils/matcher.ts +37 -0
  224. package/src/utils/metadata.ts +17 -15
  225. package/src/utils/parse-shell.ts +24 -16
  226. package/src/utils/paths.ts +15 -1
  227. package/src/utils/text.ts +4 -0
  228. package/testing/fake-claude.d.ts +4 -2
  229. package/testing/fake-claude.d.ts.map +1 -1
  230. package/testing/fake-claude.js +18 -12
  231. package/testing/fake-elements.js +4 -2
  232. package/testing/fake-files.js +1 -1
  233. package/testing/fake-sessions.d.ts +74 -0
  234. package/testing/fake-sessions.d.ts.map +1 -0
  235. package/testing/fake-sessions.js +170 -0
  236. package/testing.d.ts +33 -13
  237. package/testing.d.ts.map +1 -1
  238. package/testing.js +92 -21
  239. package/ui/components.d.ts +2 -3
  240. package/ui/components.d.ts.map +1 -1
  241. package/ui/components.js +3 -3
  242. package/ui/elements.d.ts.map +1 -1
  243. package/ui/elements.js +19 -9
  244. package/ui/markdown.d.ts +35 -33
  245. package/ui/markdown.d.ts.map +1 -1
  246. package/ui/markdown.js +14 -16
  247. package/ui/places.d.ts +43 -0
  248. package/ui/places.d.ts.map +1 -0
  249. package/ui/places.js +31 -0
  250. package/utils/call-effects.d.ts +1 -1
  251. package/utils/call-effects.d.ts.map +1 -1
  252. package/utils/call-effects.js +2 -2
  253. package/utils/changelog.d.ts +9 -0
  254. package/utils/changelog.d.ts.map +1 -0
  255. package/utils/changelog.js +29 -0
  256. package/utils/matcher.d.ts +13 -0
  257. package/utils/matcher.d.ts.map +1 -0
  258. package/utils/matcher.js +32 -0
  259. package/utils/metadata.d.ts +3 -3
  260. package/utils/metadata.js +18 -14
  261. package/utils/parse-shell.d.ts +1 -0
  262. package/utils/parse-shell.d.ts.map +1 -1
  263. package/utils/parse-shell.js +22 -13
  264. package/utils/paths.d.ts +2 -0
  265. package/utils/paths.d.ts.map +1 -1
  266. package/utils/paths.js +13 -1
  267. package/utils/text.d.ts +1 -0
  268. package/utils/text.d.ts.map +1 -1
  269. package/utils/text.js +3 -0
  270. package/src/ui/slots.ts +0 -36
  271. package/ui/slots.d.ts +0 -208
  272. package/ui/slots.d.ts.map +0 -1
  273. package/ui/slots.js +0 -19
@@ -1,23 +1,34 @@
1
- import type { Args, EventResult, Frozen, HookBudget, HookStream, PluginOptions, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
2
- import type { HookInput, Mod, ModDefinition, ModEvent, ModHook, PaneHandle } from '../mod.js'
1
+ import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement } from 'claude-code'
2
+ import type { Step } from '../installer.js'
3
+ import { hooks, PreToolUse, type Allowed, type Disposable, type Hook, type HookInput, type HookListener, type HookOptions, type Imports, type Mod, type ModDefinition, type PaneHandle, type SettingsFile, type Toast } from '../mod.js'
3
4
  import type { Options, OptionValues } from '../options.js'
4
- import { consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
5
- import { relativePath } from '../utils/paths.js'
6
- import { formatExit, listed, messageOf } from '../utils/text.js'
5
+ import { changelogPage, consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
6
+ import { ifMissing, relativePath } from '../utils/paths.js'
7
+ import { firstLine, formatExit, listed, messageOf } from '../utils/text.js'
7
8
  import type { Claude } from './claude.js'
8
9
  import { beforeDeadline } from './deadline.js'
9
- import { answerCall, dependencyCalls, notInstalled } from './dependencies.js'
10
+ import { announcedOf, announcementMs, announcementOf } from './announce.js'
11
+ import { bind } from './bound.js'
12
+ import { serveConversations } from './conversations.js'
13
+ import { answerCall, cmodPluginName, dependencyCalls, notInstalled, withBareRefusals } from './dependencies.js'
14
+ import { keepEngine } from './engine.js'
10
15
  import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, type RoutedEvent } from './hooks.js'
11
- import { checkedAnswer, checkingGrants, checksAnswers, checkWrite, isCovered, itemOf, passedDown, type AnswerCheck, type Grants } from './grants.js'
12
- import { locatingPrograms, type Programs } from './programs.js'
13
- import { createModFiles } from './metadata.js'
16
+ import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
17
+ import { cmodError, lastLineOf, locatingPrograms, readLines, type Programs } from './programs.js'
18
+ import { createModFiles, resolvedPath, type Places } from './metadata.js'
14
19
  import { fitsOption } from '../options.js'
15
20
  import { createInstaller } from './installer.js'
16
21
  import { createOptions, MissingOptions } from './options.js'
17
22
  import { createRouter, type Router, type RouterNext } from './router.js'
23
+ import { conversationMethods, createSessionsClient } from './sessions.js'
24
+ import type { SessionChange } from '../session/conversation.js'
25
+ import { matches } from '../utils/matcher.js'
18
26
  import { createState } from './state.js'
27
+ import { createToaster, showFailure, type Failure, type ShownToast, type Toaster } from './toaster.js'
19
28
  import { toolCalls } from './tool-calls.js'
20
- import { createProgress, createUi, type Progress, type ProgressLine } from './ui.js'
29
+ import { createUi } from './ui.js'
30
+ import { Progress } from '../ui/components.js'
31
+ import { Box, Text } from '../ui/elements.js'
21
32
 
22
33
  export type Plugin = {
23
34
  readonly name: string
@@ -36,6 +47,7 @@ export type Lifecycle<State extends object> = {
36
47
  readonly phase: Phase
37
48
  readonly mod: Mod<State> | undefined
38
49
  readonly failure: unknown
50
+ readonly toasts: readonly ShownToast[]
39
51
  start(claude: Claude, read: (claude: Claude) => Promise<Plugin>): Promise<void>
40
52
  route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
41
53
  }
@@ -43,8 +55,9 @@ export type Lifecycle<State extends object> = {
43
55
  type ModRuntime = {
44
56
  readonly claude: Claude
45
57
  readonly router: Router
46
- readonly progress: Progress
58
+ readonly toaster: Toaster
47
59
  readonly dataFolder: string
60
+ readonly store: string
48
61
  readonly steps: Steps
49
62
  readonly granted: ReadonlySet<string>
50
63
  readonly refreshGrants: () => Promise<void>
@@ -55,33 +68,30 @@ type ModRuntime = {
55
68
  type ActiveMod<State extends object, Declared extends Options> = {
56
69
  readonly mod: Mod<State, Declared>
57
70
  readonly added: readonly string[]
71
+ readonly imports: Imports
58
72
  }
59
73
 
60
- const cmodPluginName = 'cmod'
61
74
 
62
75
  const announcedKey = 'cmod:announced'
63
76
 
77
+ const installId = 'cmod:install'
78
+
64
79
  const cmodCheckMs = 1000
65
80
 
66
81
  const cmodWaitMs = 60_000
67
82
 
68
- const sessionStartHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
83
+ const startHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
69
84
 
70
85
  const dependencyCallMs = 30_000
71
86
 
72
87
  const cannotStart = /failed to start: /
73
88
 
74
- const missingPath = /(?:^|: )(?:ENOENT|ENOTDIR)\b/
75
-
76
89
  const shellTools: readonly string[] = ['Bash', 'PowerShell']
77
90
 
78
91
  export async function readPlugin(claude: Claude): Promise<Plugin> {
79
92
  const { name, root } = claude.plugin
80
93
  const read: ReadFile = async (path) => {
81
- const stat = await claude.fs.stat(path).catch((error: unknown) => {
82
- if (missingPath.test(messageOf(error))) return undefined
83
- throw error
84
- })
94
+ const stat = await claude.fs.stat(path).catch(ifMissing(undefined))
85
95
  return stat?.kind === 'file' ? claude.fs.read(path) : undefined
86
96
  }
87
97
  const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`)) as { version?: unknown } | undefined
@@ -144,13 +154,13 @@ export function createLifecycle<State extends object, Declared extends Options =
144
154
  ): Lifecycle<State> {
145
155
  let router: Router = createRouter()
146
156
  let phase: Phase = 'starting'
147
- let runtime: Pick<ModRuntime, 'claude' | 'progress'> | undefined
157
+ let runtime: Pick<ModRuntime, 'claude' | 'toaster'> | undefined
148
158
  let programs: Programs | undefined
149
159
  let plugin: Plugin | undefined
150
160
  const granted = new Set<string>()
151
- let line: ProgressLine | undefined
152
161
  let activation: Promise<void> | undefined
153
162
  let mod: Mod<State> | undefined
163
+ let imports: Imports | undefined
154
164
  let failure: unknown
155
165
  let pluginOptions = options
156
166
  const installer = createInstaller(definition.name, () => claude())
@@ -165,20 +175,17 @@ export function createLifecycle<State extends object, Declared extends Options =
165
175
  return runtime.claude
166
176
  }
167
177
 
168
- const showLine = () => {
169
- line ??= runtime?.progress.start(`Installing ${definition.name}`)
170
- return line as ProgressLine
171
- }
178
+ const installing = (description?: string | RenderElement) => runtime?.toaster.toast.loading('Installing', { id: installId, ...(description === undefined ? {} : { description }) })
172
179
 
173
- const endLine = () => {
174
- line?.end()
175
- line = undefined
180
+ const toastFailure = (shown: Failure) => {
181
+ if (runtime !== undefined) showFailure({ name: definition.name, ui: runtime.toaster, claude: runtime.claude }, shown)
176
182
  }
177
183
 
178
- const fail = (reason: string, fix: string) => {
184
+ const fail = (reason: string, fix: string, retry?: () => Promise<void>) => {
179
185
  phase = 'failed'
180
186
  failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'} ${fix}`)
181
- showLine().fail(reason, fix)
187
+ const action = retry === undefined ? {} : { action: { label: 'Retry', onClick: () => ((failure = undefined), retry().catch(report)) } }
188
+ runtime?.toaster.toast.error(reason, { id: installId, description: fix, duration: Infinity, ...action })
182
189
  }
183
190
 
184
191
  const finish = async () => {
@@ -190,7 +197,7 @@ export function createLifecycle<State extends object, Declared extends Options =
190
197
  const recordThroughCmod = async () => {
191
198
  if (plugin === undefined || (await cmodVersion(claude())) === undefined) return
192
199
  const { code, lastError } = await readLines(claude().process.spawn({ argv: ['cmod', 'setup', plugin.root, '--events'] }), (text) => {
193
- if (parseEvent(text).kind === 'done') shouldRecord = false
200
+ if (parseEvent(text).type === 'done') shouldRecord = false
194
201
  })
195
202
  if (shouldRecord) throw new Error(`cmod setup ${formatExit(code, lastError)}`)
196
203
  }
@@ -206,11 +213,11 @@ export function createLifecycle<State extends object, Declared extends Options =
206
213
  const saveOption = async (key: string, text: string): Promise<string | undefined> => {
207
214
  const option = (definition.options as Options | undefined)?.[key]
208
215
  if (option === undefined || plugin === undefined) return `${definition.name} has no option ${key}.`
209
- const value = option.kind === 'number' ? Number(text) : option.kind === 'toggle' ? text.trim() === 'true' : option.kind === 'list' ? text.split(',').map((item) => item.trim()).filter((item) => item !== '') : text
210
- const misfit = option.kind === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value)
216
+ const value = option.type === 'number' ? Number(text) : option.type === 'toggle' ? text.trim() === 'true' : option.type === 'list' ? text.split(',').map((item) => item.trim()).filter((item) => item !== '') : text
217
+ const misfit = option.type === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value)
211
218
  if (misfit !== undefined) return `${option.title} ${misfit}.`
212
- const saved = await claude().config.set({ key: `${plugin.name}.${key}`, value })
213
- return saved.deny
219
+ const saved = await claude().process.run(['cmod', 'option', plugin.name, key, Array.isArray(value) ? value.join(',') : String(value)])
220
+ return saved.exitCode === 0 ? undefined : cmodError(saved.stderr, 'option') || `cmod option ${formatExit(saved.exitCode, '')}.`
214
221
  }
215
222
 
216
223
  const askOptions = async (missing: MissingOptions): Promise<boolean> => {
@@ -227,19 +234,33 @@ export function createLifecycle<State extends object, Declared extends Options =
227
234
  return true
228
235
  }
229
236
 
237
+ const isStepDone = async (active: Mod<State>, step: Step<State>) => {
238
+ try {
239
+ return await step.isDone(active)
240
+ } catch (error) {
241
+ toastFailure({ id: `step:${step.title}`, message: `The ${step.title} step's isDone failed`, error })
242
+ return false
243
+ }
244
+ }
245
+
230
246
  const pendingSteps = async (active: Mod<State>) => {
231
247
  const pending: string[] = []
232
- for (const step of definition.installer ?? []) if (!(await Promise.resolve(step.isDone(active)).catch(() => false))) pending.push(step.title)
248
+ for (const step of definition.installer ?? []) if (!(await isStepDone(active, step))) pending.push(step.title)
233
249
  return pending
234
250
  }
235
251
 
236
252
  const runSteps = async (active: Mod<State>) => {
237
253
  const steps = asksPerson ? (definition.installer ?? []) : []
238
254
  for (const [index, step] of steps.entries()) {
239
- if (await Promise.resolve(step.isDone(active)).catch(() => false)) continue
255
+ if (await isStepDone(active, step)) continue
240
256
  const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length))
241
257
  if (!isAnswered) {
242
- claude().ui.log(`${definition.name} needs ${steps.length - index === 1 ? 'one more step' : `${steps.length - index} more steps`}, starting with ${step.title}. Run /mods ${definition.name} to finish.`)
258
+ runtime?.toaster.toast.warning(steps.length - index === 1 ? 'Needs one more step' : `Needs ${steps.length - index} more steps`, {
259
+ id: 'cmod:steps',
260
+ description: `Starting with ${step.title}. Finish them in /mods ${definition.name}.`,
261
+ duration: Infinity,
262
+ action: { label: 'Finish', onClick: () => active.settings.open() },
263
+ })
243
264
  break
244
265
  }
245
266
  }
@@ -249,7 +270,7 @@ export function createLifecycle<State extends object, Declared extends Options =
249
270
  const activate = async (): Promise<void> => {
250
271
  if (runtime === undefined || plugin === undefined) return
251
272
  const activeRouter = createRouter()
252
- const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error: unknown) => {
273
+ const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), store: plugin.store, steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error: unknown) => {
253
274
  if (error instanceof MissingOptions && (await askOptions(error))) return undefined
254
275
  fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : 'Fix it, then run /reload-plugins.')
255
276
  throw error
@@ -257,47 +278,58 @@ export function createLifecycle<State extends object, Declared extends Options =
257
278
  if (active === undefined) return activate()
258
279
  const { added } = active
259
280
  mod = active.mod
281
+ imports = active.imports
260
282
  router = activeRouter
261
283
  phase = 'active'
262
284
  runtime.claude.ui.invalidate('ui.render')
263
- endLine()
264
- void runSteps(active.mod).catch((error: unknown) => claude().ui.log(`${definition.name}: its installer stopped: ${messageOf(error)}`))
285
+ void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
265
286
  const missed = missedSessionStart
266
287
  missedSessionStart = undefined
267
288
  const lateAnswer = missed === undefined ? {} : await activeRouter.dispatch('classic.SessionStart', missed, async () => ({})).catch((error: unknown) => {
268
- claude().ui.log(`${definition.name}: the SessionStart hook failed: ${messageOf(error)}`)
289
+ toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error })
269
290
  return {}
270
291
  })
271
292
  if (Object.keys(lateAnswer).length > 0) {
272
293
  runtime.claude.ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read the answer of its SessionStart hooks.`, { to: 'debug' })
273
294
  }
274
295
  const version = plugin.version ?? ''
275
- if ((await runtime.claude.store.get(announcedKey)) === version) return
276
- await runtime.claude.store.set(announcedKey, version)
277
- runtime.claude.ui.toast(`${definition.name} is ready`)
278
- if (added.length > 0) runtime.claude.ui.log(`${definition.name} added ${listed(added)}.`)
296
+ const announced = announcedOf(await runtime.claude.store.get(announcedKey))
297
+ if (announced?.version === version) {
298
+ runtime.toaster.toast.dismiss(installId)
299
+ return
300
+ }
301
+ await runtime.claude.store.set(announcedKey, { version, added })
302
+ const { toaster } = runtime
303
+ void announcementOf(runtime.claude, plugin, granted, added, announced)
304
+ .then(({ message, ...options }) => toaster.toast.success(message, { id: installId, duration: announcementMs, ...options }))
305
+ .catch((error: unknown) => toastFailure({ id: installId, message: 'Could not say what it installed', error }))
279
306
  }
280
307
 
281
308
  const updatesToShow = async () => {
282
309
  const [home, configHome] = await Promise.all([claude().env.home(), claude().env.configHome()])
283
310
  const configRoot = configHome ?? `${home ?? ''}/.claude`
284
- const known = await claude().fs.read(`${configRoot}/plugins/known_marketplaces.json`).then((text): unknown => JSON.parse(text), () => undefined)
311
+ const known = await claude()
312
+ .fs.read(`${configRoot}/plugins/known_marketplaces.json`)
313
+ .then((text): unknown => JSON.parse(text))
314
+ .catch(ifMissing(undefined))
285
315
  return updatesToTurnOn(marketplaceOf(claude().plugin.root, claude().plugin.name), await claude().settings.read({ source: 'user' }), known)
286
316
  }
287
317
 
288
- const askConsent = async (event: Extract<RunnerEvent, { kind: 'needs-consent' }>) => {
318
+ const askConsent = async (event: Extract<RunnerEvent, { type: 'needs-consent' }>) => {
289
319
  const name = definition.name
290
320
  if ((await claude().session.surfaces()).length === 0) {
291
- showLine().wait(`Waiting for consent: run cmod install ${name}`)
292
321
  claude().ui.log(`${name} waits for consent to run ${event.install}. Run cmod install ${name} in a terminal.`)
293
322
  return
294
323
  }
295
- showLine().wait('Waiting for your answer')
296
- if (await installer.consent({ ...event, updates: await updatesToShow().catch(() => undefined) })) return install(event.sha256)
324
+ installing('Waiting for your answer')
325
+ const updates = await updatesToShow().catch((error: unknown) => {
326
+ toastFailure({ id: 'cmod:updates', message: 'Could not read whether its marketplace updates automatically', error })
327
+ return undefined
328
+ })
329
+ if (await installer.consent({ ...event, updates })) return install(event.sha256)
297
330
  await installer.close()
298
331
  phase = 'declined'
299
- endLine()
300
- claude().ui.log(`${name} is not installed. Run cmod install ${name} to install it.`)
332
+ runtime?.toaster.toast('Not installed', { id: installId, description: `Run cmod install ${name} to install it.` })
301
333
  }
302
334
 
303
335
  const install = async (consent?: string): Promise<void> => {
@@ -306,29 +338,28 @@ export function createLifecycle<State extends object, Declared extends Options =
306
338
  phase = 'installing'
307
339
  const found = await cmodVersion(claude())
308
340
  if (!fits(found)) return waitForcmod(found)
309
- endLine()
310
- const progress = showLine()
341
+ installing()
311
342
  let outcome: RunnerEvent | undefined
312
343
  const argv = ['cmod', 'setup', plugin.root, '--events', ...(consent === undefined ? [] : ['--consent', consent])]
313
344
  const { code, lastError } = await readLines(claude().process.spawn({ argv }), (text) => {
314
345
  const event = parseEvent(text)
315
- if (event.kind === 'progress') progress.report(event)
316
- if (event.kind === 'done' || event.kind === 'failed' || event.kind === 'needs-consent') outcome = event
346
+ if (event.type === 'progress') installing(progressOf(event))
347
+ if (event.type === 'done' || event.type === 'failed' || event.type === 'needs-consent') outcome = event
317
348
  })
318
- if (outcome?.kind === 'done') {
349
+ if (outcome?.type === 'done') {
319
350
  for (const item of plugin.steps.permissions ?? []) granted.add(item)
320
351
  return finish()
321
352
  }
322
- if (outcome?.kind === 'needs-consent') return askConsent(outcome)
323
- const reason = outcome?.kind === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`
324
- fail(reason, `Fix the cause, then run: cmod install ${definition.name}`)
353
+ if (outcome?.type === 'needs-consent') return askConsent(outcome)
354
+ const reason = outcome?.type === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`
355
+ fail(reason, `Fix the cause, then retry, or run cmod install ${definition.name} in a terminal.`, install)
325
356
  }
326
357
 
327
358
  const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}))
328
359
 
329
360
  const waitForcmod = (found: string | undefined) => {
330
361
  phase = 'waiting'
331
- showLine().wait('Waiting for cmod')
362
+ installing('Waiting for cmod')
332
363
  let latest = found
333
364
  let check: Promise<void> | undefined
334
365
  const stop = () => {
@@ -350,7 +381,7 @@ export function createLifecycle<State extends object, Declared extends Options =
350
381
  })
351
382
  })
352
383
  const longWait = claude().clock.after(cmodWaitMs, () =>
353
- showLine().wait(
384
+ installing(
354
385
  latest === undefined
355
386
  ? "Still waiting for cmod to download cmod. See cmod's own line."
356
387
  : `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.steps ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`,
@@ -359,14 +390,14 @@ export function createLifecycle<State extends object, Declared extends Options =
359
390
  }
360
391
 
361
392
  const bootstrap = async (root: string) => {
362
- const progress = showLine()
393
+ installing()
363
394
  phase = 'installing'
364
395
  const { code, lastError } = await readLines(claude().process.spawn({ argv: ['sh', '-c', './setup/bootstrap.sh'], cwd: root }), (text) => {
365
396
  const event = parseEvent(text)
366
- if (event.kind === 'progress') progress.report(event)
397
+ if (event.type === 'progress') installing(progressOf(event))
367
398
  })
368
399
  if (code === 0) return finish()
369
- fail(`bootstrap ${formatExit(code, lastError)}`, `Run ./setup/bootstrap.sh in ${root} to see the whole log.`)
400
+ fail(`bootstrap ${formatExit(code, lastError)}`, `Run ./setup/bootstrap.sh in ${root} to see the whole log.`, () => bootstrap(root))
370
401
  }
371
402
 
372
403
  const report = (error: unknown) => {
@@ -375,14 +406,30 @@ export function createLifecycle<State extends object, Declared extends Options =
375
406
  }
376
407
 
377
408
  let consentMs = 0
378
- const consentStamp = async (path: string) => {
379
- const stat = await claude().fs.stat(path).catch(() => undefined)
409
+ const stampOf = async (path: string) => {
410
+ const stat = await claude().fs.stat(path).catch(ifMissing(undefined))
380
411
  return stat?.kind === 'file' ? stat.mtimeMs : 0
381
412
  }
413
+ let recordMs = 0
414
+ let readStarted: ((claude: Claude) => Promise<Plugin>) | undefined
415
+ const recover = async () => {
416
+ if (plugin === undefined || readStarted === undefined || (phase !== 'failed' && phase !== 'declined')) return
417
+ const modifiedMs = await stampOf(recordPath(plugin.store, plugin.name))
418
+ if (modifiedMs === recordMs) return
419
+ recordMs = modifiedMs
420
+ const current = await readStarted(claude())
421
+ if (!current.isInstalled) return
422
+ plugin = current
423
+ failure = undefined
424
+ activation = undefined
425
+ await refreshGrants()
426
+ await finish()
427
+ }
428
+
382
429
  const refreshGrants = async () => {
383
430
  if (plugin === undefined || plugin.name === cmodPluginName) return
384
431
  const path = consentPath(plugin.store)
385
- const modifiedMs = await consentStamp(path)
432
+ const modifiedMs = await stampOf(path)
386
433
  if (modifiedMs === consentMs) return
387
434
  consentMs = modifiedMs
388
435
  const approved = modifiedMs === 0 ? [] : (parseConsent(JSON.parse(await claude().fs.read(path)), path)[plugin.name] ?? [])
@@ -403,7 +450,7 @@ export function createLifecycle<State extends object, Declared extends Options =
403
450
  droppedParts.add(item)
404
451
  const declared = plugin?.steps.permissions ?? []
405
452
  const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`
406
- claude().ui.log(`${definition.name} answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it. ${fix}`)
453
+ runtime?.toaster.toast.warning(`Answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it`, { id: `dropped:${item}`, description: fix, duration: Infinity })
407
454
  },
408
455
  }
409
456
 
@@ -419,14 +466,16 @@ export function createLifecycle<State extends object, Declared extends Options =
419
466
  if (activation !== undefined && phase !== 'active') await activation
420
467
  }
421
468
 
422
- const holdSessionStart = () =>
423
- new Promise<void>((resolve) => {
424
- const limit = claude().clock.after(sessionStartHoldMs, resolve)
425
- void startSettled.then(whenActive).then(() => {
426
- limit.cancel()
427
- resolve()
428
- })
429
- })
469
+ const holdUntilStarted = (): Promise<void> =>
470
+ runtime === undefined
471
+ ? startSettled.then(whenActive)
472
+ : new Promise<void>((resolve) => {
473
+ const limit = claude().clock.after(startHoldMs, resolve)
474
+ void startSettled.then(whenActive).then(() => {
475
+ limit.cancel()
476
+ resolve()
477
+ })
478
+ })
430
479
 
431
480
  return {
432
481
  get phase() {
@@ -438,10 +487,13 @@ export function createLifecycle<State extends object, Declared extends Options =
438
487
  get failure() {
439
488
  return failure
440
489
  },
490
+ get toasts() {
491
+ return runtime?.toaster.shown ?? []
492
+ },
441
493
  async start(claudeCalls, read) {
442
494
  if (runtime !== undefined) return
443
495
  programs = locatingPrograms(claudeCalls)
444
- runtime = { claude: programs.claude, progress: createProgress(claudeCalls) }
496
+ runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name) }
445
497
  try {
446
498
  await programs.refresh()
447
499
  const started = await read(claudeCalls).catch((error: unknown) => {
@@ -450,8 +502,10 @@ export function createLifecycle<State extends object, Declared extends Options =
450
502
  })
451
503
  if (started === undefined) return
452
504
  plugin = started
505
+ readStarted = read
453
506
  for (const item of started.granted) granted.add(item)
454
- consentMs = await consentStamp(consentPath(started.store))
507
+ consentMs = await stampOf(consentPath(started.store))
508
+ recordMs = await stampOf(recordPath(started.store, started.name))
455
509
  if (started.isInstalled) {
456
510
  shouldRecord = started.shouldRecord
457
511
  if (shouldRecord) record()
@@ -464,6 +518,7 @@ export function createLifecycle<State extends object, Declared extends Options =
464
518
  }
465
519
  },
466
520
  async route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
521
+ if (imports !== undefined) bind(imports)
467
522
  if (event === 'ui.render' && installer.draws(e as Frozen<Args<'ui.render'>>)) return installer.draw(e as Frozen<Args<'ui.render'>>) as EventResult<N>
468
523
  if (event === 'ui.close') installer.closed((e as Frozen<Args<'ui.close'>>).id)
469
524
  if (event === 'classic.SessionStart') missedSessionStart = undefined
@@ -472,9 +527,11 @@ export function createLifecycle<State extends object, Declared extends Options =
472
527
  return next(e)
473
528
  }
474
529
  if (shouldRecord && event === 'classic.UserPromptSubmit') record()
475
- await (event === 'classic.SessionStart' ? holdSessionStart() : whenActive())
530
+ if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart') await recover().catch((error: unknown) => claude().ui.log(`${definition.name} stays stopped: ${messageOf(error)}`, { to: 'debug' }))
531
+ const isCallToThisMod = event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name
532
+ await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
476
533
  if (event === 'classic.SessionStart' && phase !== 'active') missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
477
- if (event === 'cmod.call' && phase !== 'active' && (e as Frozen<Args<'cmod.call'>>).to === definition.name) {
534
+ if (isCallToThisMod && phase !== 'active') {
478
535
  const isStopped = phase === 'declined' || phase === 'failed'
479
536
  return { deny: isStopped ? notInstalled(definition.name) : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
480
537
  }
@@ -487,17 +544,18 @@ export function createLifecycle<State extends object, Declared extends Options =
487
544
  if (checksAnswers(event) && phase === 'active') return checkedRoute(event, e, next)
488
545
  const answer = router.dispatch(event, e, next)
489
546
  const render = e as Frozen<Args<'ui.render'>>
490
- if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined || !runtime.progress.isShown) return answer as Promise<EventResult<N>>
491
- return runtime.progress.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.viewport?.columns) as EventResult<N>
547
+ if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined) return answer
548
+ return runtime.toaster.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.props) as EventResult<N>
492
549
  },
493
550
  }
494
551
  }
495
552
 
496
553
  async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
497
- const { router, progress } = runtime
498
- const claude = checkingKeys(runtime.claude, definition.name, runtime.steps.keys ?? {})
554
+ const { router, toaster } = runtime
555
+ const report = (failure: Failure) => void showFailure({ name: definition.name, ui: toaster, claude: runtime.claude }, failure)
556
+ const claude = withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {}))
499
557
  const added: string[] = []
500
- const hookEvents: ModEvent[] = []
558
+ const hooksAdded: Hook[] = []
501
559
  const announce = (feature: string) => {
502
560
  added.push(feature)
503
561
  }
@@ -513,18 +571,22 @@ async function createMod<State extends object, Declared extends Options>(definit
513
571
  configRoot,
514
572
  projectRoot: () => modState.root,
515
573
  freeFolders: () => [modState.root, runtime.dataFolder],
574
+ freeAddresses: () => programAddresses(mod),
516
575
  }
517
576
  const checked = checkingGrants(claude, grants)
577
+ const places: Places = { home, configRoot, projectRoot: () => modState.root }
518
578
  const files = createModFiles({
519
579
  name: definition.name,
520
580
  claude,
521
- places: { home, configRoot, projectRoot: () => modState.root },
581
+ places,
522
582
  checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
583
+ report,
523
584
  })
524
585
  const pages = new Map<string, { readonly title: string; readonly handle: PaneHandle }>()
525
586
  const settingsPages: Mod<State>['settings'] = {
526
587
  page(page) {
527
588
  if (pages.has(page.id)) throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`)
589
+ if (page.id === changelogPage) throw new Error(`${definition.name}: the settings page id "${changelogPage}" opens the mod's CHANGELOG.md in /mods. Give the page another id.`)
528
590
  pages.set(page.id, { title: page.title, handle: area.ui.pane(page) })
529
591
  },
530
592
  async open(pageId) {
@@ -540,81 +602,141 @@ async function createMod<State extends object, Declared extends Options>(definit
540
602
  let cwd = startCwd
541
603
  let loadedCwd = startCwd
542
604
  let staleCwd: string | undefined
543
- const area = createUi<State>({ name: definition.name, claude, router, progress, announce, mod: () => mod })
544
- const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed })
545
- const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, changed: modState.changed })
605
+ let forkedFrom: string | undefined
606
+ let parentAsked: Promise<string | undefined> | undefined
607
+ let hasEarlierPrompts = true
608
+ const area = createUi<State>({ name: definition.name, claude, router, toaster, report, announce, mod: () => mod })
609
+ const askParent = async (): Promise<string | undefined> => {
610
+ if (definition.name === cmodPluginName) return undefined
611
+ try {
612
+ const parent = await claude.cmod.call({ to: cmodPluginName, method: 'forkParent', input: null })
613
+ return typeof parent === 'string' ? parent : undefined
614
+ } catch (error) {
615
+ claude.ui.log(`${definition.name} starts without the session values of the conversation it was forked from: ${messageOf(error)}`, { to: 'debug' })
616
+ return undefined
617
+ }
618
+ }
619
+ const forkParent = () =>
620
+ (parentAsked ??= askParent().then((parent) => {
621
+ forkedFrom = parent
622
+ return parent
623
+ }))
624
+ const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
625
+ const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
546
626
  const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
547
- const mod: Mod<State, Declared> = {
548
- name: definition.name,
549
- state: modState.state,
550
- get options() {
551
- return modOptions.values as OptionValues<Declared>
552
- },
553
- dataFolder: runtime.dataFolder,
554
- on(event, hook, options) {
555
- const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude)
556
- if (!hookEvents.includes(event)) hookEvents.push(event)
557
- if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, bounded as ModHook<'PreToolUse'>, claude, agents, held))
558
- else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>, bounded as ModHook<Exclude<ModEvent, 'PreToolUse'>>, claude, agents))
559
- },
560
- use: (job) => job({ mod, announce, reserveName, toolCalls: agents }),
561
- every(ms, hook) {
562
- let isRunning = false
563
- return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
564
- if (isRunning) return
565
- isRunning = true
566
- void Promise.resolve()
567
- .then(hook)
568
- .catch((error: unknown) => claude.ui.log(`${definition.name}: the every ${ms} ms hook failed: ${messageOf(error)}`))
569
- .finally(() => {
570
- isRunning = false
571
- })
572
- })
573
- },
574
- ui: area.ui,
575
- process: {
576
- run: (argv, init) => checked.process.run(argv, init),
577
- spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
627
+ const expand = (path: string) => (path === '~' || path.startsWith('~/') ? resolvedPath(path, places) : path)
628
+ const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
629
+ read: () => checked.settings.read({ source }),
630
+ async update(values) {
631
+ await checkSettings(grants, source, Object.keys(values))
632
+ await claude.cmod.call({ to: cmodPluginName, method: 'updateSettings', input: { mod: definition.name, source, project: modState.root, values } })
578
633
  },
634
+ })
635
+ const client = createSessionsClient({ name: definition.name, claude: checked, report: (id, message, error) => report({ id, message, error }) })
636
+ let followed: Parameters<typeof client.follow>[0] | undefined
637
+ const followFolders = (project: string, folder: string) => {
638
+ if (followed === undefined) return
639
+ followed = { ...followed, project, cwd: folder }
640
+ client.follow(followed)
641
+ }
642
+ const imports: Imports = {
643
+ sessions: client.sessions,
644
+ settings: { user: settingsFile('user'), project: settingsFile('project'), local: settingsFile('local') },
645
+ model: { complete: (completion, options) => checked.model.complete(completion, options) },
579
646
  fs: {
580
- read: (path) => checked.fs.read(path),
581
- write: (path, text) => checked.fs.write(path, text),
582
- list: (path) => checked.fs.list(path),
583
- exists: (path) => checked.fs.exists(path),
584
- stat: (path, options) => checked.fs.stat(path, options),
647
+ read: (path) => checked.fs.read(expand(path)),
648
+ write: (path, text) => checked.fs.write(expand(path), text),
649
+ exists: (path) => checked.fs.exists(expand(path)),
585
650
  find: (glob) => files.find(glob),
651
+ list: async (path) => (await checked.fs.list(path === undefined ? undefined : expand(path))).map(({ name, kind, size, mtimeMs, isLink }) => ({ name, isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs })),
652
+ async stat(path, options) {
653
+ const { kind, size, mtimeMs, isLink, realPath } = await checked.fs.stat(expand(path), options)
654
+ return { isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs, ...(realPath === undefined ? {} : { realPath }) }
655
+ },
586
656
  },
587
657
  metadata: {
588
658
  read: (path) => files.read(path),
589
659
  update: (path, change) => files.update(path, change),
590
660
  },
661
+ process: {
662
+ run: (argv, init) => checked.process.run(argv, init),
663
+ spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
664
+ },
591
665
  http: { fetch: (url, init) => checked.http.fetch(url, init) },
592
- settings: settingsPages,
593
- session: {
594
- messages: ((args?: { readonly agentId: string }) => (args === undefined ? checked.session.messages() : checked.session.messages({ agentId: args.agentId }))) as Mod<State>['session']['messages'],
595
- async append(text) {
596
- const added = await checked.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
597
- if (added.deny !== undefined) throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`)
598
- },
599
- async submit(text) {
600
- const submitted = await checked.prompt.submit({ text })
601
- if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`)
602
- },
666
+ }
667
+ const allowed = (permission: string): Allowed => ({ allows: (value) => isCovered(granted, itemOf(permission, value), home) })
668
+ const flag = (permission: string) => isCovered(granted, permission, home)
669
+ const timer = (ms: number, subject: string, start: (ms: number, run: () => void) => { cancel(): void }, listener: () => unknown): Disposable => {
670
+ let isRunning = false
671
+ const started = start(checkedMs(definition.name, `mod.${subject}`, ms), () => {
672
+ if (isRunning) return
673
+ isRunning = true
674
+ void Promise.resolve()
675
+ .then(() => {
676
+ bind(imports)
677
+ return listener()
678
+ })
679
+ .catch((error: unknown) => report({ id: `${subject}:${ms}`, message: `The ${subject} ${ms} ms timer failed`, error }))
680
+ .finally(() => {
681
+ isRunning = false
682
+ })
683
+ })
684
+ return { dispose: () => started.cancel() }
685
+ }
686
+ const mod: Mod<State, Declared> = {
687
+ name: definition.name,
688
+ folder: claude.plugin.root,
689
+ dataFolder: runtime.dataFolder,
690
+ state: modState.state,
691
+ get options() {
692
+ return modOptions.values as OptionValues<Declared>
603
693
  },
604
- agent: { spawn: (args) => checked.agent.spawn(args) },
605
- model: { complete: (completion, options) => checked.model.complete(completion, options) },
606
694
  permissions: {
607
- has: (name: string, value?: string) => isCovered(granted, itemOf(name, value), home),
608
- },
609
- claude: { ...checked, on: (event, hook) => router.add(event, hook) },
610
- get projectRoot() {
611
- return modState.root
695
+ get conversation() {
696
+ return flag('conversation')
697
+ },
698
+ get prompt() {
699
+ return flag('prompt')
700
+ },
701
+ get model() {
702
+ return flag('model')
703
+ },
704
+ get tasks() {
705
+ return flag('tasks')
706
+ },
707
+ get tools() {
708
+ return flag('tools')
709
+ },
710
+ get approve() {
711
+ return flag('approve')
712
+ },
713
+ network: allowed('network'),
714
+ run: allowed('run'),
715
+ files: allowed('files'),
716
+ config: allowed('config'),
612
717
  },
613
- get cwd() {
614
- return cwd
718
+ on(hook: Hook, matcherOrListener: unknown, listenerOrOptions?: unknown, maybeOptions?: HookOptions): Disposable {
719
+ const hasMatcher = typeof listenerOrOptions === 'function'
720
+ const listener = (hasMatcher ? listenerOrOptions : matcherOrListener) as HookListener<Hook>
721
+ const matcher = hasMatcher ? matcherOrListener : undefined
722
+ const options = (hasMatcher ? maybeOptions : listenerOrOptions) as HookOptions | undefined
723
+ if (!hooks.includes(hook)) throw new Error(`${definition.name}: mod.on takes one of Claude Code's hooks, such as PreToolUse, not ${String(hook)}.`)
724
+ const matched: HookListener<Hook> = matcher === undefined ? listener : (input) => (matches(input, matcher) ? listener(input) : undefined)
725
+ const bounded = options?.timeoutMs === undefined ? matched : timedHook(hook, matched, checkedMs(definition.name, `the ${hook} hook's timeoutMs`, options.timeoutMs), claude, toaster.toast)
726
+ if (!hooksAdded.includes(hook)) hooksAdded.push(hook)
727
+ if (hook === PreToolUse) return router.add('tool.call', preToolUseHook(definition.name, bounded as HookListener<typeof PreToolUse>, claude, agents, held))
728
+ const classic: Exclude<Hook, typeof PreToolUse> = hook
729
+ return router.add(`classic.${classic}`, classicHook(definition.name, classic, bounded as HookListener<typeof classic>, claude, agents, report))
615
730
  },
731
+ every: (ms, listener) => timer(ms, 'every', claude.clock.every, listener),
732
+ after: (ms, listener) => timer(ms, 'after', claude.clock.after, listener),
733
+ use: (job) => job({ mod, announce, reserveName }),
734
+ log: (text) => claude.ui.log(`${definition.name}: ${text}`, { to: 'debug' }),
735
+ ui: { ...area.ui, notice: (callId, text) => claude.ui.notice(callId, text) },
736
+ settings: settingsPages,
616
737
  dependencies: dependencyCalls(claude, (call, task) => beforeDeadline(claude, { ms: dependencyCallMs }, call, task)),
617
738
  }
739
+ bind(imports)
618
740
  const followSession = async (isAfterCd = false) => {
619
741
  try {
620
742
  const [nextRoot, reportedCwd] = await Promise.all([claude.session.root(), claude.session.cwd()])
@@ -625,8 +747,10 @@ async function createMod<State extends object, Declared extends Options>(definit
625
747
  const nextCwd = staleCwd === undefined ? reportedCwd : nextRoot
626
748
  if (!hasMovedRoot && nextCwd === oldCwd) return
627
749
  cwd = nextCwd
750
+ followFolders(nextRoot, nextCwd)
628
751
  await modState.moveTo(nextRoot).catch((error: unknown) => {
629
752
  if (cwd === nextCwd) cwd = loadedCwd
753
+ followFolders(modState.root, cwd)
630
754
  throw error
631
755
  })
632
756
  if (hasMovedRoot) await modOptions.load(nextRoot)
@@ -636,18 +760,33 @@ async function createMod<State extends object, Declared extends Options>(definit
636
760
  const moved: HookInput<'CwdChanged'> = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
637
761
  await router.dispatch('classic.CwdChanged', moved as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
638
762
  } catch (error) {
639
- claude.ui.log(`${definition.name} keeps the state of ${modState.root} until the next prompt or folder move: ${messageOf(error)}`)
763
+ report({ id: 'cmod:follow', message: `Keeps the state of ${modState.root} until the next prompt or folder move`, error })
640
764
  }
641
765
  }
766
+ const followCurrent = async (title: string | undefined) => {
767
+ const [id, model, usage] = await Promise.all([claude.session.id(), claude.session.model(), claude.session.usage()])
768
+ followed = { id, title: title ?? '', project: modState.root, cwd, model, startedAt: new Date(usage.startedAt).toISOString(), ...(forkedFrom === undefined ? {} : { forkedFrom }) }
769
+ client.follow(followed)
770
+ }
642
771
  router.add('classic.SessionStart', async (e, next) => {
643
- await modState.switchSession(e.session_id, e.source).catch((error: unknown) => {
644
- claude.ui.log(`${definition.name} kept its session values from before the ${e.source}: ${messageOf(error)}`)
772
+ const previous = followed?.id
773
+ if (e.source === 'fork' && previous !== undefined && previous !== e.session_id) forkedFrom = previous
774
+ else if (e.session_id !== previous) {
775
+ forkedFrom = undefined
776
+ parentAsked = undefined
777
+ }
778
+ hasEarlierPrompts = e.source !== 'startup' && e.source !== 'clear'
779
+ await modState.switchSession(e.session_id, e.source, e.transcript_path).catch((error: unknown) => {
780
+ report({ id: 'cmod:session', message: `Kept its session values from before the ${e.source}`, error })
645
781
  })
782
+ await followCurrent(e.session_title).catch((error: unknown) => report({ id: 'cmod:current', message: 'Kept the details of the session from before', error }))
646
783
  return next(e)
647
784
  })
648
785
  router.add('classic.UserPromptSubmit', async (e, next) => {
649
786
  staleCwd = undefined
650
787
  await followSession()
788
+ const isPerson = e.source === undefined || e.source === 'user' || e.source === 'sdk'
789
+ await followCurrent(e.session_title || (isPerson && !hasEarlierPrompts && client.sessions.current.title === '' ? firstLine(e.prompt) : undefined)).catch((error: unknown) => report({ id: 'cmod:current', message: 'Kept the details of the session from before', error }))
651
790
  return next(e)
652
791
  })
653
792
  for (const event of ['classic.PostToolUse', 'classic.PostToolUseFailure'] as const) {
@@ -663,44 +802,56 @@ async function createMod<State extends object, Declared extends Options>(definit
663
802
  })
664
803
  router.add('skill.prompt', userSkillHook(claude))
665
804
  const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input: never) => run(input, mod)]))
805
+ const conversations = definition.name === cmodPluginName ? serveConversations({ claude, router, store: runtime.store, project: () => modState.root, report: (id, message, error) => report({ id, message, error }) }) : undefined
666
806
  router.add('cmod.call', async (e, next) => {
667
807
  if (e.to !== definition.name) return next(e)
808
+ const call = e as typeof e & { readonly caller?: string }
809
+ if (e.method === conversationMethods.changed && call.caller === cmodPluginName) {
810
+ if (!flag('conversation')) return { value: null }
811
+ const { session: changedSession, change } = e.input as { readonly session: string; readonly change: SessionChange }
812
+ client.changed(changedSession, change)
813
+ return { value: null }
814
+ }
668
815
  if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
669
816
  if (e.method === openPageMethod) {
670
817
  await settingsPages.open(String(e.input))
671
818
  return { value: null }
672
819
  }
673
- return answerCall(definition.name, api, e)
820
+ const served = await conversations?.answer(call)
821
+ return served ?? answerCall(definition.name, api, e)
674
822
  })
675
823
  const agents = toolCalls(claude, router)
824
+ keepEngine(mod, { claude: checked, router, toolCalls: agents, report })
676
825
  const names = new Set<string>()
677
- const reserveName = (kind: string, name: string, taken: string) => {
678
- const key = `${kind}:${name}`
826
+ const reserveName = (type: string, name: string, taken: string): Disposable => {
827
+ const key = `${type}:${name}`
679
828
  if (names.has(key)) throw new Error(taken)
680
829
  names.add(key)
830
+ return { dispose: () => void names.delete(key) }
681
831
  }
682
832
  await failsAs('its state did not load', () => modState.load())
683
833
  await failsAs('its options did not load', () => modOptions.load(root))
684
834
  const missing = modOptions.missing
685
835
  if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
836
+ await failsAs('the details of its session did not load', () => followCurrent(undefined))
686
837
  await failsAs('its setup function threw', () => definition.setup(mod))
687
838
  const permissionHooks = permissionEvents.filter((event) => router.has(event))
688
839
  if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
689
840
  throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
690
841
  }
691
- if (held !== undefined && hookEvents.includes('PreToolUse')) router.add('tool.check', heldDecisionHook(held))
842
+ if (held !== undefined && hooksAdded.includes(PreToolUse)) router.add('tool.check', heldDecisionHook(held))
692
843
  await failsAs('its open panes did not load', () => area.restorePanes())
693
- if (hookEvents.length > 0) added.push(`${hookEvents.length === 1 ? 'a hook' : 'hooks'} on ${listed(hookEvents)}`)
694
- return { mod, added }
844
+ if (hooksAdded.length > 0) added.push(`${hooksAdded.length === 1 ? 'a hook' : 'hooks'} on ${listed(hooksAdded)}`)
845
+ return { mod, added, imports }
695
846
  }
696
847
 
697
- function checkingKeys(claude: Claude, name: string, keys: Readonly<Record<string, string>>): Claude {
848
+ function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
698
849
  const bound = Object.entries(keys)
699
850
  if (bound.length === 0) return claude
700
851
  const register: Claude['command']['register'] = (command) => {
701
852
  for (const [key, commandName] of bound) {
702
853
  if (commandName === command.name && command.immediate !== true) {
703
- claude.ui.log(`${name}: ${key} runs /${command.name}, which waits for Claude's turn to end and adds a row to the conversation. Give /${command.name} immediate: true.`)
854
+ toast.warning(`${key} runs /${command.name}, which waits for Claude's turn to end`, { id: `key:${key}`, description: `It adds a row to the conversation. Give /${command.name} immediate: true.`, duration: Infinity })
704
855
  }
705
856
  }
706
857
  return claude.command.register(command)
@@ -715,11 +866,11 @@ function checkedMs(name: string, subject: string, ms: number): number {
715
866
  return ms
716
867
  }
717
868
 
718
- function timedHook<E extends ModEvent>(name: string, event: E, hook: ModHook<E>, ms: number, claude: Claude): ModHook<E> {
869
+ function timedHook<H extends Hook>(event: H, hook: HookListener<H>, ms: number, claude: Claude, toast: Toast): HookListener<H> {
719
870
  return (input) =>
720
871
  new Promise((resolve, reject) => {
721
872
  const timer = claude.clock.after(ms, () => {
722
- claude.ui.log(`${name}: the ${event} hook passed its ${ms / 1000} s timeout`)
873
+ toast.warning(`The ${event} hook passed its ${ms / 1000} s timeout`, { id: `timeout:${event}` })
723
874
  resolve(undefined)
724
875
  })
725
876
  Promise.resolve()
@@ -745,25 +896,6 @@ async function failsAs(subject: string, step: () => unknown): Promise<void> {
745
896
  }
746
897
  }
747
898
 
748
- async function readLines(stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>, onLine: (text: string) => void): Promise<{ code: number | null; lastError: string }> {
749
- let pending = ''
750
- let lastError = ''
751
- let piece = await stream.next()
752
- while (piece.done !== true) {
753
- if (piece.value.stream === 'stderr') {
754
- lastError = lastLineOf(piece.value.text) ?? lastError
755
- } else {
756
- const lines = `${pending}${piece.value.text}`.split('\n')
757
- pending = lines.pop() ?? ''
758
- for (const text of lines) onLine(text)
759
- }
760
- piece = await stream.next()
761
- }
762
- if (pending !== '') onLine(pending)
763
- return { code: piece.value.code, lastError }
764
- }
765
-
766
- function lastLineOf(text: string): string | undefined {
767
- const trimmed = text.trim()
768
- return trimmed === '' ? undefined : trimmed.slice(trimmed.lastIndexOf('\n') + 1).trim()
899
+ function progressOf({ done, total, label }: Extract<RunnerEvent, { type: 'progress' }>): RenderElement {
900
+ return Box({ gap: 2, children: [Progress({ value: total > 0 ? (done / total) * 100 : 0 }), Text({ dimColor: true, wrap: 'truncate-end', children: label })] })
769
901
  }