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