@cmodjs/core 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/docs/commands.md +39 -22
  2. package/docs/dependencies.md +27 -20
  3. package/docs/index.md +21 -17
  4. package/docs/install-steps.md +37 -29
  5. package/docs/jobs.md +25 -52
  6. package/docs/mod.md +94 -66
  7. package/docs/options.md +2 -2
  8. package/docs/permissions.md +7 -5
  9. package/docs/session.md +14 -12
  10. package/docs/testing.md +55 -32
  11. package/docs/ui.md +255 -263
  12. package/hotkeys.d.ts +22 -0
  13. package/hotkeys.d.ts.map +1 -0
  14. package/hotkeys.js +26 -0
  15. package/installer.d.ts +1 -1
  16. package/installer.d.ts.map +1 -1
  17. package/installer.js +11 -6
  18. package/jobs/check.d.ts +2 -1
  19. package/jobs/check.d.ts.map +1 -1
  20. package/jobs/check.js +2 -2
  21. package/jobs/context.d.ts +2 -1
  22. package/jobs/context.d.ts.map +1 -1
  23. package/jobs/context.js +1 -1
  24. package/jobs/permissions.d.ts +2 -1
  25. package/jobs/permissions.d.ts.map +1 -1
  26. package/jobs/permissions.js +2 -2
  27. package/jobs/program.js +1 -1
  28. package/jobs/prompt.d.ts +2 -1
  29. package/jobs/prompt.d.ts.map +1 -1
  30. package/jobs/prompt.js +20 -13
  31. package/jobs/schedule.d.ts +2 -1
  32. package/jobs/schedule.d.ts.map +1 -1
  33. package/jobs/schedule.js +15 -1
  34. package/jobs/slash-command.d.ts +6 -2
  35. package/jobs/slash-command.d.ts.map +1 -1
  36. package/jobs/slash-command.js +6 -5
  37. package/jobs/tool.d.ts +6 -4
  38. package/jobs/tool.d.ts.map +1 -1
  39. package/jobs/tool.js +3 -3
  40. package/mod.d.ts +63 -42
  41. package/mod.d.ts.map +1 -1
  42. package/mod.js +11 -1
  43. package/package.json +1 -1
  44. package/records.d.ts +14 -8
  45. package/records.d.ts.map +1 -1
  46. package/records.js +44 -31
  47. package/register.d.ts.map +1 -1
  48. package/register.js +1 -2
  49. package/runtime/announce.d.ts +1 -1
  50. package/runtime/announce.d.ts.map +1 -1
  51. package/runtime/announce.js +6 -7
  52. package/runtime/before.d.ts +2 -2
  53. package/runtime/before.d.ts.map +1 -1
  54. package/runtime/before.js +1 -1
  55. package/runtime/claude.d.ts +0 -1
  56. package/runtime/claude.d.ts.map +1 -1
  57. package/runtime/conversations.d.ts +4 -1
  58. package/runtime/conversations.d.ts.map +1 -1
  59. package/runtime/conversations.js +42 -14
  60. package/runtime/dependencies.d.ts.map +1 -1
  61. package/runtime/dependencies.js +2 -1
  62. package/runtime/engine.d.ts +5 -0
  63. package/runtime/engine.d.ts.map +1 -1
  64. package/runtime/grants.js +2 -2
  65. package/runtime/hooks.d.ts +1 -1
  66. package/runtime/hooks.d.ts.map +1 -1
  67. package/runtime/hotkeys.d.ts +15 -0
  68. package/runtime/hotkeys.d.ts.map +1 -0
  69. package/runtime/hotkeys.js +58 -0
  70. package/runtime/installer.d.ts +5 -2
  71. package/runtime/installer.d.ts.map +1 -1
  72. package/runtime/installer.js +23 -12
  73. package/runtime/lifecycle.d.ts.map +1 -1
  74. package/runtime/lifecycle.js +190 -141
  75. package/runtime/options.js +2 -2
  76. package/runtime/pages.d.ts +28 -0
  77. package/runtime/pages.d.ts.map +1 -0
  78. package/runtime/pages.js +98 -0
  79. package/runtime/parts.d.ts +14 -0
  80. package/runtime/parts.d.ts.map +1 -0
  81. package/runtime/parts.js +63 -0
  82. package/runtime/permission-check.d.ts +2 -2
  83. package/runtime/permission-check.d.ts.map +1 -1
  84. package/runtime/permission-check.js +12 -4
  85. package/runtime/programs.js +1 -1
  86. package/runtime/router.d.ts +2 -2
  87. package/runtime/router.d.ts.map +1 -1
  88. package/runtime/router.js +1 -1
  89. package/runtime/sessions.d.ts +10 -9
  90. package/runtime/sessions.d.ts.map +1 -1
  91. package/runtime/sessions.js +14 -5
  92. package/runtime/state.d.ts +2 -1
  93. package/runtime/state.d.ts.map +1 -1
  94. package/runtime/state.js +1 -0
  95. package/runtime/toaster.d.ts +1 -1
  96. package/runtime/toaster.d.ts.map +1 -1
  97. package/runtime/toaster.js +10 -4
  98. package/runtime/ui.d.ts +87 -5
  99. package/runtime/ui.d.ts.map +1 -1
  100. package/runtime/ui.js +372 -109
  101. package/runtime/watches.d.ts +9 -0
  102. package/runtime/watches.d.ts.map +1 -0
  103. package/runtime/watches.js +73 -0
  104. package/runtime/widgets.d.ts +48 -0
  105. package/runtime/widgets.d.ts.map +1 -0
  106. package/runtime/widgets.js +219 -0
  107. package/session/context/turn/message/message.d.ts.map +1 -1
  108. package/session/context/turn/message/message.js +7 -2
  109. package/session/session.d.ts +11 -5
  110. package/session/session.d.ts.map +1 -1
  111. package/src/hotkeys.ts +51 -0
  112. package/src/installer.ts +8 -4
  113. package/src/jobs/check.ts +4 -3
  114. package/src/jobs/context.ts +3 -2
  115. package/src/jobs/permissions.ts +4 -3
  116. package/src/jobs/program.ts +1 -1
  117. package/src/jobs/prompt.ts +23 -14
  118. package/src/jobs/schedule.ts +16 -2
  119. package/src/jobs/slash-command.ts +10 -7
  120. package/src/jobs/tool.ts +7 -5
  121. package/src/mod.ts +59 -47
  122. package/src/records.ts +50 -31
  123. package/src/register.ts +2 -3
  124. package/src/runtime/announce.ts +6 -7
  125. package/src/runtime/before.ts +4 -4
  126. package/src/runtime/claude.ts +0 -1
  127. package/src/runtime/conversations.ts +66 -20
  128. package/src/runtime/dependencies.ts +2 -1
  129. package/src/runtime/engine.ts +5 -0
  130. package/src/runtime/grants.ts +2 -2
  131. package/src/runtime/hooks.ts +1 -0
  132. package/src/runtime/hotkeys.ts +80 -0
  133. package/src/runtime/installer.ts +24 -12
  134. package/src/runtime/lifecycle.ts +189 -136
  135. package/src/runtime/options.ts +2 -2
  136. package/src/runtime/pages.ts +127 -0
  137. package/src/runtime/parts.ts +72 -0
  138. package/src/runtime/permission-check.ts +13 -5
  139. package/src/runtime/programs.ts +1 -1
  140. package/src/runtime/router.ts +3 -3
  141. package/src/runtime/sessions.ts +24 -15
  142. package/src/runtime/state.ts +3 -1
  143. package/src/runtime/toaster.ts +10 -4
  144. package/src/runtime/ui.ts +405 -115
  145. package/src/runtime/watches.ts +75 -0
  146. package/src/runtime/widgets.ts +287 -0
  147. package/src/session/context/turn/message/message.ts +7 -2
  148. package/src/session/session.ts +10 -6
  149. package/src/testing/fake-claude.ts +9 -6
  150. package/src/testing/fake-elements.ts +7 -4
  151. package/src/testing/fake-sessions.ts +6 -4
  152. package/src/testing.ts +203 -46
  153. package/src/ui/areas.ts +27 -0
  154. package/src/ui/elements.ts +35 -0
  155. package/src/ui/markdown.ts +18 -18
  156. package/src/ui/panes.ts +73 -0
  157. package/src/ui/styles.ts +53 -0
  158. package/src/utils/subscription.ts +1 -0
  159. package/testing/fake-claude.d.ts +0 -1
  160. package/testing/fake-claude.d.ts.map +1 -1
  161. package/testing/fake-claude.js +9 -5
  162. package/testing/fake-elements.js +7 -4
  163. package/testing/fake-sessions.d.ts +2 -1
  164. package/testing/fake-sessions.d.ts.map +1 -1
  165. package/testing/fake-sessions.js +1 -0
  166. package/testing.d.ts +21 -6
  167. package/testing.d.ts.map +1 -1
  168. package/testing.js +206 -45
  169. package/ui/areas.d.ts +29 -0
  170. package/ui/areas.d.ts.map +1 -0
  171. package/ui/areas.js +6 -0
  172. package/ui/elements.d.ts +3 -0
  173. package/ui/elements.d.ts.map +1 -1
  174. package/ui/elements.js +36 -0
  175. package/ui/markdown.d.ts +14 -14
  176. package/ui/markdown.d.ts.map +1 -1
  177. package/ui/markdown.js +11 -11
  178. package/ui/panes.d.ts +71 -0
  179. package/ui/panes.d.ts.map +1 -0
  180. package/ui/panes.js +16 -0
  181. package/ui/styles.d.ts +44 -0
  182. package/ui/styles.d.ts.map +1 -0
  183. package/ui/styles.js +24 -0
  184. package/utils/subscription.d.ts +4 -0
  185. package/utils/subscription.d.ts.map +1 -0
  186. package/jobs/status-line.d.ts +0 -6
  187. package/jobs/status-line.d.ts.map +0 -1
  188. package/jobs/status-line.js +0 -39
  189. package/src/jobs/status-line.ts +0 -44
  190. package/src/ui/define-pane.ts +0 -27
  191. package/src/ui/places.ts +0 -49
  192. package/src/utils/disposable.ts +0 -3
  193. package/ui/define-pane.d.ts +0 -15
  194. package/ui/define-pane.d.ts.map +0 -1
  195. package/ui/define-pane.js +0 -12
  196. package/ui/places.d.ts +0 -43
  197. package/ui/places.d.ts.map +0 -1
  198. package/ui/places.js +0 -31
  199. package/utils/disposable.d.ts +0 -4
  200. package/utils/disposable.d.ts.map +0 -1
  201. /package/utils/{disposable.js → subscription.js} +0 -0
@@ -1,8 +1,9 @@
1
1
  import type { Args, ClassicHookInputs, EventResult, Frozen, HookBudget, HookFailure, PluginOptions, RenderElement, Timer } from 'claude-code'
2
2
  import type { Step } from '../installer.js'
3
- import type { Allowed, FileChange, Disposable, Imports, Mod, ModDefinition, PaneHandle, SettingsFile, SettingsFileChange, SettingsFileChangeListener, Toast } from '../mod.js'
3
+
4
+ import type { Allowed, Imports, Mod, ModDefinition, Reservation, SettingsFile, SettingsFileChange, SettingsFileChangeListener, Subscription, Timer as ModTimer } from '../mod.js'
4
5
  import type { Options, OptionValues } from '../options.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 { consentPath, dataFolder, finishStepsMethod, grantFix, coreVersion, isAtLeast, marketplaceOf, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
6
7
  import { homePrefix, ifMissing, relativePath } from '../utils/paths.js'
7
8
  import { firstLine, formatExit, messageOf } from '../utils/text.js'
8
9
  import type { Claude } from './claude.js'
@@ -15,20 +16,25 @@ import { keepEngine } from './engine.js'
15
16
  import { classicFailure, classicHook, userSkillHook, type Hook, type HookFields, type HookListener, type RoutedEvent } from './hooks.js'
16
17
  import type { PermissionRoutes } from './permission-check.js'
17
18
  import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
18
- import { cmodError, lastLineOf, locatingPrograms, readLines, type Programs } from './programs.js'
19
+ import { cmodError, locatingPrograms, readLines, type Programs } from './programs.js'
19
20
  import { createModFiles, resolvedPath, type Places } from './metadata.js'
20
21
  import { fitsOption } from '../options.js'
21
22
  import { createInstaller } from './installer.js'
22
23
  import { createOptions, MissingOptions } from './options.js'
23
24
  import { createRouter, type Router, type RouterNext } from './router.js'
24
25
  import { conversationMethods, createSessionsClient, type SessionsClient } from './sessions.js'
25
- import type { SessionChange } from '../session/conversation.js'
26
+ import type { SessionChange, TurnData } from '../session/conversation.js'
26
27
  import type { BeforeEvent } from '../session/session.js'
27
28
  import { matches } from '../utils/matcher.js'
28
29
  import { createState } from './state.js'
29
30
  import { createToaster, type Failure, type ShownToast, type Toaster } from './toaster.js'
30
31
  import { toolCalls } from './tool-calls.js'
31
- import { createUi } from './ui.js'
32
+ import { createHotkeys } from './hotkeys.js'
33
+ import { servePages, type Pages } from './pages.js'
34
+ import { drawnParts } from './parts.js'
35
+ import { createWatches } from './watches.js'
36
+ import { createUi, partMethods, type PartDraw, type PartsUpdate, type SettingsTab, type UiArea } from './ui.js'
37
+ import { serveWidgets } from './widgets.js'
32
38
  import { Progress } from '../ui/components.js'
33
39
  import { Box, Text } from '../ui/elements.js'
34
40
 
@@ -74,12 +80,18 @@ type ActiveMod<State extends object, Declared extends Options> = {
74
80
  readonly added: readonly string[]
75
81
  readonly imports: Imports
76
82
  readonly raise: SessionsClient['raise']
83
+ readonly area: UiArea<State>
77
84
  }
78
85
 
79
86
 
80
87
  const announcedKey = 'cmod:announced'
81
88
 
89
+ const cmodUpdatedKey = 'cmod:cmod-updated-for'
90
+
82
91
  const installId = 'cmod:install'
92
+ const askingId = 'cmod:asking'
93
+
94
+ const recordId = 'cmod:record'
83
95
 
84
96
  const installRetrySeconds = [2, 4, 8, 16, 32, 64, 128] as const
85
97
 
@@ -95,7 +107,6 @@ const startHoldMs = hookMs - 1_000
95
107
 
96
108
  const dependencyCallMs = 30_000
97
109
 
98
- const cannotStart = /failed to start: /
99
110
 
100
111
  const shellTools: readonly string[] = ['Bash', 'PowerShell']
101
112
 
@@ -122,7 +133,9 @@ export async function readPlugin(claude: Claude): Promise<Plugin> {
122
133
  read: (path) => read(`${root}/${path}`),
123
134
  list: (folder) => filesBelow(claude, `${root}/${folder}`),
124
135
  })
125
- return { ...plugin, isInstalled: version !== undefined && record?.version === version && record.scriptsSha256 === scripts, shouldRecord: false }
136
+ if (version === undefined || record?.scriptsSha256 !== scripts) return { ...plugin, isInstalled: false, shouldRecord: false }
137
+ const isInstalled = record.version === version || record.root === root
138
+ return { ...plugin, isInstalled, shouldRecord: isInstalled && record.version !== version }
126
139
  }
127
140
 
128
141
  async function filesBelow(claude: Claude, folder: string): Promise<string[]> {
@@ -148,15 +161,17 @@ async function readJson(read: ReadFile, path: string): Promise<unknown> {
148
161
  }
149
162
 
150
163
  async function cmodVersion(claude: Claude): Promise<string | undefined> {
151
- const result = await claude.process.run(['cmod', '--version']).catch((error: unknown) => {
152
- if (cannotStart.test(messageOf(error))) return undefined
153
- throw error
154
- })
155
- if (result === undefined) return undefined
156
- if (result.exitCode !== 0) throw new Error(`cmod --version ${formatExit(result.exitCode, lastLineOf(result.stderr) ?? '')}`)
157
- return result.stdout.trim().split(/\s+/).at(-1)
164
+ const store = storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() })
165
+ const exists = async (path: string) => (await claude.fs.stat(path).catch(ifMissing(undefined))) !== undefined
166
+ if (!(await exists(`${store}/programs/cmod`))) return undefined
167
+ const versions = (await claude.fs.list(`${store}/bin/cmod`).catch(ifMissing([]))).filter((entry) => entry.kind === 'dir' && releaseVersion.test(entry.name)).map((entry) => entry.name)
168
+ const newestFirst = versions.toSorted((one, other) => (isAtLeast(one, other) ? -1 : 1))
169
+ for (const version of newestFirst) if (await exists(`${store}/bin/cmod/${version}/cmod`)) return version
170
+ return undefined
158
171
  }
159
172
 
173
+ const releaseVersion = /^\d+\.\d+\.\d+$/
174
+
160
175
  export function createLifecycle<State extends object, Declared extends Options = Options>(
161
176
  definition: ModDefinition<State, string, Declared>,
162
177
  permissionRoutes: () => PermissionRoutes | undefined = () => undefined,
@@ -171,14 +186,24 @@ export function createLifecycle<State extends object, Declared extends Options =
171
186
  const granted = new Set<string>()
172
187
  let activation: Promise<void> | undefined
173
188
  let mod: Mod<State> | undefined
189
+ const openSettings = async (tab?: SettingsTab) => {
190
+ await claude().cmod.call({ to: cmodPluginName, method: partMethods.changed, input: { requests: [{ settings: true, ...(tab === undefined ? {} : { tab }) }], isAsked: true } })
191
+ }
174
192
  let imports: Imports | undefined
175
193
  let raise: SessionsClient['raise'] | undefined
176
194
  let failure: unknown
177
195
  let pluginOptions = options
178
- const installer = createInstaller(definition.name, () => claude())
196
+ const installer = createInstaller(definition.name, () => claude(), {
197
+ show: (open) => {
198
+ if (phase !== 'active') runtime?.toaster.toast.dismiss(installId)
199
+ runtime?.toaster.toast.info('Installing', { id: askingId, description: 'Waiting for your answer', duration: Infinity, action: { label: 'Answer', onClick: open } })
200
+ },
201
+ hide: () => runtime?.toaster.toast.dismiss(askingId),
202
+ })
179
203
  let shouldRecord = false
180
204
  let recording: Promise<void> | undefined
181
205
  let missedSessionStart: Frozen<Args<'classic.SessionStart'>> | undefined
206
+ let hasHeardSessionStart = false
182
207
  let settleStart: () => void = () => undefined
183
208
  const startSettled = new Promise<void>((resolve) => (settleStart = resolve))
184
209
 
@@ -191,17 +216,18 @@ export function createLifecycle<State extends object, Declared extends Options =
191
216
 
192
217
  const toastFailure = (shown: Failure) => runtime?.toaster.fail(shown)
193
218
 
194
- const fail = (reason: string, fix: string | undefined, retry?: () => Promise<void>) => {
219
+ const fail = (reason: string, fix: string | undefined, retry?: () => Promise<void>, error?: unknown) => {
195
220
  phase = 'failed'
196
- failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'}${fix === undefined ? '' : ` ${fix}`}`)
221
+ failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'}${fix === undefined ? '' : ` ${fix}`}`, { cause: error })
197
222
  const again = retry === undefined ? {} : { retry: () => ((failure = undefined), stopRetrying(), retry().catch(report)) }
198
- toastFailure({ id: installId, message: reason, ...(fix === undefined ? {} : { description: fix }), ...again })
223
+ toastFailure({ id: installId, message: reason, ...(fix === undefined ? {} : { description: fix }), ...(error === undefined ? {} : { error }), ...again })
199
224
  }
200
225
 
201
226
  let consented: string | undefined
202
227
  const installAgain = () => install(consented)
228
+ const updateAgain = () => claude().store.delete(cmodUpdatedKey).then(installAgain)
203
229
 
204
- type Retrying = { readonly when: 'changed' | 'later'; readonly tries: number; readonly checkout: string | undefined; readonly reason: string; readonly timer: Timer }
230
+ type Retrying = { readonly when: 'changed' | 'later'; readonly tries: number; readonly reason: string; readonly timer: Timer }
205
231
  let retrying: Retrying | undefined
206
232
 
207
233
  const retryLater = (next: Omit<Retrying, 'timer'>) => {
@@ -216,29 +242,20 @@ export function createLifecycle<State extends object, Declared extends Options =
216
242
  retrying = undefined
217
243
  }
218
244
 
219
- const checkoutState = async (): Promise<string | undefined> => {
220
- if (plugin === undefined) return undefined
221
- const lines: string[] = []
222
- 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))
223
- return code === 0 ? lines.join('\n') : undefined
224
- }
225
-
226
- const failInstall = async (reason: string, when: 'changed' | 'later' | undefined) => {
245
+ const failInstall = (reason: string, when: 'changed' | 'later' | undefined) => {
227
246
  if (when === undefined) {
228
247
  stopRetrying()
229
248
  return fail(reason, undefined, installAgain)
230
249
  }
231
- retryLater({ when, tries: retrying?.when === when ? retrying.tries : 0, checkout: when === 'changed' ? await checkoutState() : undefined, reason })
250
+ retryLater({ when, tries: retrying?.when === when ? retrying.tries : 0, reason })
232
251
  }
233
252
 
234
253
  const retryInBackground = async () => {
235
- const waiting = retrying
236
- if (phase !== 'failed' || waiting === undefined) return
254
+ if (phase !== 'failed' || retrying === undefined) return
237
255
  if (await recover()) {
238
256
  stopRetrying()
239
257
  return whenActive()
240
258
  }
241
- if (waiting.when === 'changed' && waiting.checkout !== undefined && (await checkoutState()) === waiting.checkout) return retryLater(waiting)
242
259
  await install(consented, true)
243
260
  await whenActive()
244
261
  }
@@ -246,15 +263,24 @@ export function createLifecycle<State extends object, Declared extends Options =
246
263
  const finish = async () => {
247
264
  await programs?.refresh()
248
265
  phase = 'ready'
266
+ activation ??= new Promise<void>((resolve) => claude().clock.after(0, () => resolve(activate().catch(report))))
249
267
  claude().ui.invalidate('ui.render')
250
268
  }
251
269
 
252
270
  const recordThroughCmod = async () => {
253
271
  if (plugin === undefined || (await cmodVersion(claude())) === undefined) return
272
+ let failed: string | undefined
254
273
  const { code, lastError } = await readLines(claude().process.spawn({ argv: ['cmod', 'setup', plugin.root, '--events'] }), (text) => {
255
- if (parseEvent(text).type === 'done') shouldRecord = false
274
+ const event = parseEvent(text)
275
+ if (event.type === 'done') shouldRecord = false
276
+ if (event.type === 'failed') failed = event.message
256
277
  })
257
- if (shouldRecord) throw new Error(`cmod setup ${formatExit(code, lastError)}`)
278
+ if (!shouldRecord) {
279
+ runtime?.toaster.toast.dismiss(recordId)
280
+ return programs?.refresh()
281
+ }
282
+ if (failed !== undefined) return void toastFailure({ id: recordId, message: failed, retry: record })
283
+ throw new Error(`cmod setup ${formatExit(code, lastError)}`)
258
284
  }
259
285
 
260
286
  const record = () => {
@@ -312,9 +338,9 @@ export function createLifecycle<State extends object, Declared extends Options =
312
338
  if (!isAnswered) {
313
339
  runtime?.toaster.toast.warning(steps.length - index === 1 ? 'Needs one more step' : `Needs ${steps.length - index} more steps`, {
314
340
  id: 'cmod:steps',
315
- description: `Starting with ${step.title}. Finish them in /mods ${definition.name}.`,
341
+ description: `Starting with ${step.title}.`,
316
342
  duration: Infinity,
317
- action: { label: 'Finish', onClick: () => active.settings.open() },
343
+ action: { label: 'Finish', onClick: () => runSteps(active) },
318
344
  })
319
345
  break
320
346
  }
@@ -327,7 +353,7 @@ export function createLifecycle<State extends object, Declared extends Options =
327
353
  const activeRouter = createRouter()
328
354
  const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), store: plugin.store, steps: plugin.steps, granted, refreshGrants, permissionRoutes, options: pluginOptions }).catch(async (error: unknown) => {
329
355
  if (error instanceof MissingOptions && (await askOptions(error))) return undefined
330
- fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : undefined, begin)
356
+ fail(messageOf(error), error instanceof MissingOptions ? `Retry asks for ${error.keys.length === 1 ? 'it' : 'them'}.` : undefined, begin, error)
331
357
  throw error
332
358
  })
333
359
  if (active === undefined) return activate()
@@ -341,19 +367,7 @@ export function createLifecycle<State extends object, Declared extends Options =
341
367
  void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
342
368
  const missed = missedSessionStart
343
369
  missedSessionStart = undefined
344
- const replay = (e: Frozen<Args<'classic.SessionStart'>>) => (definition.name === cmodPluginName ? activeRouter.dispatch('classic.SessionStart', e, async () => ({})) : checkedRoute('classic.SessionStart', e, async () => ({})))
345
- const lateAnswer = missed === undefined ? {} : await replay(missed).catch((error: unknown) => {
346
- toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error })
347
- return {}
348
- })
349
- const { additionalContext, ...unread } = lateAnswer as { readonly additionalContext?: readonly string[] } & Record<string, unknown>
350
- for (const text of additionalContext ?? []) {
351
- const added = await runtime.claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
352
- if (added.deny !== undefined) runtime.claude.ui.log(`${definition.name}: Claude Code refused its SessionStart note: ${added.deny}`, { to: 'debug' })
353
- }
354
- if (Object.keys(unread).length > 0) {
355
- runtime.claude.ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read ${Object.keys(unread).join(', ')} from its SessionStart hooks.`, { to: 'debug' })
356
- }
370
+ if (missed !== undefined) await replaySessionStart(missed)
357
371
  const version = plugin.version ?? ''
358
372
  const announced = announcedOf(await runtime.claude.store.get(announcedKey))
359
373
  if (announced?.version === version) {
@@ -362,11 +376,28 @@ export function createLifecycle<State extends object, Declared extends Options =
362
376
  }
363
377
  await runtime.claude.store.set(announcedKey, { version, added })
364
378
  const { toaster } = runtime
365
- void announcementOf(runtime.claude, plugin, granted, added, announced)
379
+ void announcementOf(runtime.claude, plugin, granted, added, announced, openSettings)
366
380
  .then(({ message, ...options }) => toaster.toast.success(message, { id: installId, duration: announcementMs, ...options }))
367
381
  .catch((error: unknown) => toastFailure({ id: installId, message: 'Could not say what it installed', error }))
368
382
  }
369
383
 
384
+ const replaySessionStart = async (e: Frozen<Args<'classic.SessionStart'>>) => {
385
+ hasHeardSessionStart = true
386
+ const ran = definition.name === cmodPluginName ? router.dispatch('classic.SessionStart', e, async () => ({})) : checkedRoute('classic.SessionStart', e, async () => ({}))
387
+ const answer = await ran.catch((error: unknown) => {
388
+ toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error })
389
+ return {}
390
+ })
391
+ const { additionalContext, ...unread } = answer as { readonly additionalContext?: readonly string[] } & Record<string, unknown>
392
+ for (const text of additionalContext ?? []) {
393
+ const added = await claude().session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
394
+ if (added.deny !== undefined) claude().ui.log(`${definition.name}: Claude Code refused its SessionStart note: ${added.deny}`, { to: 'debug' })
395
+ }
396
+ if (Object.keys(unread).length > 0) {
397
+ claude().ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read ${Object.keys(unread).join(', ')} from its SessionStart hooks.`, { to: 'debug' })
398
+ }
399
+ }
400
+
370
401
  const updatesToShow = async () => {
371
402
  const [home, configHome] = await Promise.all([claude().env.home(), claude().env.configHome()])
372
403
  const configRoot = configHome ?? `${home ?? ''}/.claude`
@@ -383,7 +414,6 @@ export function createLifecycle<State extends object, Declared extends Options =
383
414
  claude().ui.log(`${name} waits for consent to run ${event.install}. Run cmod install ${name} in a terminal.`)
384
415
  return
385
416
  }
386
- runtime?.toaster.toast.info('Installing', { id: installId, description: 'Waiting for your answer', duration: Infinity })
387
417
  const updates = await updatesToShow().catch((error: unknown) => {
388
418
  toastFailure({ id: 'cmod:updates', message: 'Could not read whether its marketplace updates automatically', error })
389
419
  return undefined
@@ -423,7 +453,7 @@ export function createLifecycle<State extends object, Declared extends Options =
423
453
  return failInstall(outcome?.type === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`, retries)
424
454
  }
425
455
 
426
- const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}))
456
+ const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, coreVersion)
427
457
 
428
458
  const waitForcmod = (found: string | undefined) => {
429
459
  phase = 'waiting'
@@ -453,11 +483,24 @@ export function createLifecycle<State extends object, Declared extends Options =
453
483
  const longWait = claude().clock.after(cmodWaitMs, () => {
454
484
  timer.cancel()
455
485
  timer = poll(cmodSlowCheckMs)
456
- installing(
457
- latest === undefined
458
- ? "Still waiting for cmod to download cmod. See cmod's own line."
459
- : `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.`,
460
- )
486
+ if (latest === undefined) return installing("Still waiting for cmod to download cmod. See cmod's own line.")
487
+ const found = latest
488
+ const tried = `${found} to ${coreVersion}`
489
+ return claude()
490
+ .store.get(cmodUpdatedKey)
491
+ .then(async (updated) => {
492
+ if (updated === tried) {
493
+ stop()
494
+ return fail(`${definition.name} needs cmod ${coreVersion} or later, and the newest cmod is ${found}.`, undefined, updateAgain)
495
+ }
496
+ await claude().store.set(cmodUpdatedKey, tried)
497
+ installing(`${definition.name} needs cmod ${coreVersion} or later, and this computer has cmod ${found}, so cmod is updating itself.`)
498
+ const { code, lastError } = await readLines(claude().process.spawn({ argv: ['cmod', 'update', 'cmod'] }), () => undefined)
499
+ if (code === 0) return
500
+ stop()
501
+ fail(`${definition.name} needs cmod ${coreVersion} or later, and updating cmod ${found} ${formatExit(code, lastError)}`, undefined, updateAgain)
502
+ })
503
+ .catch(report)
461
504
  })
462
505
  }
463
506
 
@@ -491,7 +534,7 @@ export function createLifecycle<State extends object, Declared extends Options =
491
534
 
492
535
  const report = (error: unknown) => {
493
536
  failure = error
494
- if (phase !== 'failed') fail(messageOf(error), undefined, begin)
537
+ if (phase !== 'failed') fail(messageOf(error), undefined, begin, error)
495
538
  }
496
539
 
497
540
  let consentMs = 0
@@ -539,8 +582,13 @@ export function createLifecycle<State extends object, Declared extends Options =
539
582
  if (droppedParts.has(item)) return
540
583
  droppedParts.add(item)
541
584
  const declared = plugin?.steps.permissions ?? []
542
- const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`
543
- runtime?.toaster.toast.warning(`Answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it`, { id: `dropped:${item}`, description: fix, duration: Infinity })
585
+ const isDeclared = declared.includes(item)
586
+ runtime?.toaster.toast.warning(`Answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it`, {
587
+ id: `dropped:${item}`,
588
+ description: isDeclared ? grantFix : `It needs "permissions": { "${item}": true } in package.json "cmod".`,
589
+ duration: Infinity,
590
+ ...(isDeclared ? { action: { label: 'Settings', onClick: () => openSettings('permissions') } } : {}),
591
+ })
544
592
  },
545
593
  }
546
594
 
@@ -588,7 +636,7 @@ export function createLifecycle<State extends object, Declared extends Options =
588
636
  async start(claudeCalls, read) {
589
637
  if (runtime !== undefined) return
590
638
  programs = locatingPrograms(claudeCalls)
591
- runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name, () => plugin?.version) }
639
+ runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name, () => plugin?.version, () => openSettings('permissions')) }
592
640
  readStarted = read
593
641
  try {
594
642
  await programs.refresh()
@@ -601,7 +649,10 @@ export function createLifecycle<State extends object, Declared extends Options =
601
649
  if (imports !== undefined) bind(imports)
602
650
  if (event === 'ui.render' && installer.draws(e as Frozen<Args<'ui.render'>>)) return installer.draw(e as Frozen<Args<'ui.render'>>) as EventResult<N>
603
651
  if (event === 'ui.close') installer.closed((e as Frozen<Args<'ui.close'>>).id)
604
- if (event === 'classic.SessionStart') missedSessionStart = undefined
652
+ if (event === 'classic.SessionStart') {
653
+ missedSessionStart = undefined
654
+ hasHeardSessionStart = true
655
+ }
605
656
  if (event === 'classic.SessionStart' && runtime === undefined) {
606
657
  missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
607
658
  return next(e)
@@ -625,6 +676,10 @@ export function createLifecycle<State extends object, Declared extends Options =
625
676
  if (call.method === finishStepsMethod) return (await runSteps(mod), { value: null }) as EventResult<N>
626
677
  }
627
678
  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' }))
679
+ if (event === 'classic.UserPromptSubmit' && phase === 'active' && !hasHeardSessionStart) {
680
+ const { session_id, transcript_path, cwd } = e as Frozen<Args<'classic.UserPromptSubmit'>>
681
+ await replaySessionStart({ session_id, transcript_path, cwd, hook_event_name: 'SessionStart', source: 'resume' })
682
+ }
628
683
  if (checksAnswers(event) && phase === 'active' && definition.name !== cmodPluginName) return checkedRoute(event, e, next)
629
684
  const answer = router.dispatch(event, e, next)
630
685
  const render = e as Frozen<Args<'ui.render'>>
@@ -647,7 +702,7 @@ export function createLifecycle<State extends object, Declared extends Options =
647
702
  async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
648
703
  const { router, toaster } = runtime
649
704
  const report = (failure: Failure) => void toaster.fail(failure)
650
- const claude = answeringOwnCalls(withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {})), router)
705
+ const claude = answeringOwnCalls(withBareRefusals(runtime.claude), router)
651
706
  const added: string[] = []
652
707
  const announce = (feature: string) => {
653
708
  added.push(feature)
@@ -675,23 +730,6 @@ async function createMod<State extends object, Declared extends Options>(definit
675
730
  checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
676
731
  report,
677
732
  })
678
- const pages = new Map<string, { readonly title: string; readonly handle: PaneHandle }>()
679
- const settingsPages: Mod<State>['settings'] = {
680
- page(page) {
681
- if (pages.has(page.id)) throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`)
682
- 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.`)
683
- pages.set(page.id, { title: page.title, handle: area.ui.pane(page) })
684
- },
685
- async open(pageId) {
686
- if (pageId === undefined) {
687
- await claude.cmod.call({ to: cmodPluginName, method: 'openSettings', input: { mod: definition.name } })
688
- return
689
- }
690
- const page = pages.get(pageId)
691
- if (page === undefined) throw new Error(`${definition.name} has no settings page "${pageId}". It has: ${[...pages.keys()].join(', ') || 'none'}.`)
692
- await page.handle.open({ focus: true })
693
- },
694
- }
695
733
  let cwd = startCwd
696
734
  let loadedCwd = startCwd
697
735
  let staleCwd: string | undefined
@@ -699,7 +737,7 @@ async function createMod<State extends object, Declared extends Options>(definit
699
737
  let parentAsked: Promise<string | undefined> | undefined
700
738
  let hasEarlierPrompts = true
701
739
  let agentType: string | undefined
702
- const area = createUi<State>({ name: definition.name, claude, router, toaster, report, announce, mod: () => mod })
740
+ const area = createUi<State>({ name: definition.name, claude, router, report, announce, mod: () => mod })
703
741
  const askParent = async (): Promise<string | undefined> => {
704
742
  if (definition.name === cmodPluginName) return undefined
705
743
  try {
@@ -717,7 +755,7 @@ async function createMod<State extends object, Declared extends Options>(definit
717
755
  }))
718
756
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
719
757
  const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
720
- const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>, failed?: (input: ClassicHookInputs[H], error: Error) => HookFields | undefined): Disposable =>
758
+ const hookOn = <H extends Hook>(hook: H, listener: HookListener<H>, failed?: (input: ClassicHookInputs[H], error: Error) => HookFields | undefined): Subscription =>
721
759
  router.add(`classic.${hook}`, classicHook(hook, listener, report), failed === undefined ? undefined : classicFailure(failed))
722
760
  const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
723
761
  const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
@@ -737,8 +775,7 @@ async function createMod<State extends object, Declared extends Options>(definit
737
775
  permissionRoutes: runtime.permissionRoutes,
738
776
  })
739
777
  let followed: Parameters<typeof client.follow>[0] | undefined
740
- let isSetUp = false
741
- const watches = new Set<{ readonly paths: readonly string[]; readonly listener: (change: FileChange) => unknown }>()
778
+ const watches = createWatches(claude, report)
742
779
  const followFolders = (project: string, folder: string) => {
743
780
  if (followed === undefined) return
744
781
  followed = { ...followed, project, cwd: folder }
@@ -794,20 +831,10 @@ async function createMod<State extends object, Declared extends Options>(definit
794
831
  const { kind, size, mtimeMs, isLink, realPath } = await checked.fs.stat(expand(path), options)
795
832
  return { isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs, ...(realPath === undefined ? {} : { realPath }) }
796
833
  },
797
- onChange({ path }, listener) {
798
- if (isSetUp) throw new Error(`${definition.name}: fs.onChange works only in setup, because Claude Code reads the paths it watches when a session starts.`)
799
- const paths = [path].flat().map((each) => resolvedPath(each, places))
800
- const watch = { paths, listener }
801
- watches.add(watch)
802
- if (watches.size === 1) {
803
- hookOn('SessionStart', () => ({ watchPaths: [...new Set([...watches].flatMap((each) => each.paths))] }))
804
- hookOn('FileChanged', ({ file_path, event }) => {
805
- for (const each of watches) if (each.paths.some((watched) => relativePath(watched, file_path) !== undefined)) void Promise.resolve(each.listener({ path: file_path, type: event })).catch((error: unknown) => report({ id: 'fs:onChange', message: 'The fs.onChange listener failed', error }))
806
- return undefined
807
- })
808
- }
809
- return { dispose: () => void watches.delete(watch) }
810
- },
834
+ onChange: ({ path }, listener) => watches.add([path].flat().map((each) => resolvedPath(each, places)), (change) => {
835
+ bind(imports)
836
+ return listener(change)
837
+ }),
811
838
  },
812
839
  metadata: {
813
840
  read: (path) => files.read(path),
@@ -818,14 +845,21 @@ async function createMod<State extends object, Declared extends Options>(definit
818
845
  spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
819
846
  },
820
847
  http: { fetch: (url, init) => checked.http.fetch(url, init) },
848
+ clipboard: {
849
+ async write(text) {
850
+ await toaster.copy(text)
851
+ },
852
+ },
821
853
  }
822
854
  const allowed = (permission: string): Allowed => ({ allows: (value) => isCovered(granted, itemOf(permission, value), home) })
823
855
  const flag = (permission: string) => isCovered(granted, permission, home)
824
- const timer = (ms: number, subject: string, start: (ms: number, run: () => void) => { cancel(): void }, listener: () => unknown): Disposable => {
825
- let isRunning = false
826
- const started = start(checkedMs(definition.name, `mod.${subject}`, ms), () => {
827
- if (isRunning) return
828
- isRunning = true
856
+ const timer = (ms: number, subject: string, repeats: boolean, listener: () => unknown): ModTimer => {
857
+ let isFiring = false
858
+ let isRunning = true
859
+ const fire = () => {
860
+ if (!repeats) isRunning = false
861
+ if (isFiring) return
862
+ isFiring = true
829
863
  void Promise.resolve()
830
864
  .then(() => {
831
865
  bind(imports)
@@ -833,11 +867,22 @@ async function createMod<State extends object, Declared extends Options>(definit
833
867
  })
834
868
  .catch((error: unknown) => report({ id: `${subject}:${ms}`, message: `The ${subject} ${ms} ms timer failed`, error }))
835
869
  .finally(() => {
836
- isRunning = false
870
+ isFiring = false
837
871
  })
838
- })
839
- return { dispose: () => started.cancel() }
872
+ }
873
+ const checked = checkedMs(definition.name, `mod.${subject}`, ms)
874
+ const started = repeats ? claude.clock.every(checked, fire) : claude.clock.after(checked, fire)
875
+ return {
876
+ get isRunning() {
877
+ return isRunning
878
+ },
879
+ stop() {
880
+ isRunning = false
881
+ started.cancel()
882
+ },
883
+ }
840
884
  }
885
+ const hotkeys = await createHotkeys({ name: definition.name, declared: definition.hotkeys ?? {}, configRoot, claude, router, answerInput: area.answerInput, report })
841
886
  const mod: Mod<State, Declared> = {
842
887
  name: definition.name,
843
888
  folder: claude.plugin.root,
@@ -870,12 +915,18 @@ async function createMod<State extends object, Declared extends Options>(definit
870
915
  files: allowed('files'),
871
916
  config: allowed('config'),
872
917
  },
873
- every: (ms, listener) => timer(ms, 'every', claude.clock.every, listener),
874
- after: (ms, listener) => timer(ms, 'after', claude.clock.after, listener),
918
+ hotkeys,
919
+ every: (ms, listener) => timer(ms, 'every', true, listener),
920
+ after: (ms, listener) => timer(ms, 'after', false, listener),
875
921
  use: (job) => job({ mod, announce, reserveName }),
876
922
  log: (text, options) => claude.ui.log(`${definition.name}: ${text}`, { to: options?.to ?? 'debug' }),
877
- ui: { ...area.ui, notice: (callId, text) => claude.ui.notice(callId, text) },
878
- settings: settingsPages,
923
+ panes: area.panes,
924
+ sidebar: area.sidebar,
925
+ settings: area.settings,
926
+ widgets: area.widgets,
927
+ styles: area.styles,
928
+ toast: toaster.toast,
929
+ ask: (question, options) => claude.ui.ask(question, options),
879
930
  dependencies: dependencyCalls(claude, (call, task) => beforeDeadline(claude, { ms: dependencyCallMs }, call, task)),
880
931
  }
881
932
  bind(imports)
@@ -947,8 +998,14 @@ async function createMod<State extends object, Declared extends Options>(definit
947
998
  return answer
948
999
  })
949
1000
  router.add('skill.prompt', userSkillHook(claude))
950
- const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input: never) => run(input, mod)]))
951
- const conversations = definition.name === cmodPluginName ? serveConversations({ claude, router, store: runtime.store, project: () => modState.root, report: (id, message, error) => report({ id, message, error }) }) : undefined
1001
+ router.add('ui.press', (e, next) => area.asked(() => next(e)))
1002
+ let api: Readonly<Record<string, (input: never) => unknown>> = {}
1003
+ const conversations = definition.name === cmodPluginName ? serveConversations({ claude, router, store: runtime.store, project: () => modState.root, report: (id, message, error) => report({ id, message, error }), clear: (id) => toaster.toast.dismiss(id) }) : undefined
1004
+ let pages: Pages | undefined
1005
+ const reportPart = (id: string, message: string, error: unknown) => report({ id, message, error })
1006
+ const parts = drawnParts({ claude, report: reportPart, press: (to, input) => pages?.press(to, input) })
1007
+ const widgets = conversations === undefined ? undefined : serveWidgets({ claude, router, store: runtime.store, report: reportPart, turnClosedBy: (rowId) => conversations.turnClosedBy(rowId), parts })
1008
+ pages = widgets === undefined ? undefined : servePages({ claude, router, report: reportPart, widgets, parts })
952
1009
  router.add('cmod.call', async (e, next) => {
953
1010
  if (e.to !== definition.name) return next(e)
954
1011
  const call = e as typeof e & { readonly caller?: string }
@@ -960,10 +1017,13 @@ async function createMod<State extends object, Declared extends Options>(definit
960
1017
  }
961
1018
  const ask = beforeAskOf(call)
962
1019
  if (ask !== undefined) return { value: (await client.answerBefore(ask.session, ask.event, ask.change, ask.isInProject)) ?? null }
963
- if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
964
- if (e.method === openPageMethod) {
965
- await settingsPages.open(String(e.input))
966
- return { value: null }
1020
+ if (e.method === partMethods.changed && call.caller !== undefined && pages !== undefined) {
1021
+ return { value: { isHeld: pages.update(call.caller, e.input as PartsUpdate) } }
1022
+ }
1023
+ if (call.caller === cmodPluginName) {
1024
+ const turn = e.method === partMethods.draw ? (e.input as { readonly turn?: TurnData }).turn : undefined
1025
+ const answered = area.answer(e.method, turn === undefined ? e.input : { ...(e.input as PartDraw), turn: client.turn(turn) })
1026
+ if (answered !== undefined) return answered
967
1027
  }
968
1028
  const served = await conversations?.answer(call)
969
1029
  return served ?? answerCall(definition.name, api, e)
@@ -973,37 +1033,30 @@ async function createMod<State extends object, Declared extends Options>(definit
973
1033
  return ask === undefined ? { deny: `${definition.name}: ${error.message}` } : { value: client.failedBefore(ask.session, ask.event, ask.change, ask.isInProject, error) ?? null }
974
1034
  })
975
1035
  const agents = toolCalls(claude, router)
976
- keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined })
1036
+ keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined, answerInput: area.answerInput, ...(widgets === undefined ? {} : { widgets }), ...(pages === undefined ? {} : { pages }) })
977
1037
  const names = new Set<string>()
978
- const reserveName = (type: string, name: string, taken: string): Disposable => {
1038
+ const reserveName = (type: string, name: string, taken: string): Reservation => {
979
1039
  const key = `${type}:${name}`
980
1040
  if (names.has(key)) throw new Error(taken)
981
1041
  names.add(key)
982
- return { dispose: () => void names.delete(key) }
1042
+ return { remove: () => void names.delete(key) }
983
1043
  }
984
1044
  await failsAs('its state did not load', () => modState.load())
985
1045
  await failsAs('its options did not load', () => modOptions.load(root))
986
1046
  const missing = modOptions.missing
987
1047
  if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
988
1048
  await failsAs('the details of its session did not load', () => followCurrent(undefined))
989
- await failsAs('its setup function threw', () => definition.setup(mod))
990
- isSetUp = true
1049
+ const returned = await failsAs('its setup function threw', () => definition.setup(mod))
1050
+ api = apiOf(definition.name, returned)
991
1051
  await failsAs('its open panes did not load', () => area.restorePanes())
992
- return { mod, added, imports, raise: client.raise }
1052
+ area.started()
1053
+ return { mod, added, imports, raise: client.raise, area }
993
1054
  }
994
1055
 
995
- function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
996
- const bound = Object.entries(keys)
997
- if (bound.length === 0) return claude
998
- const addCommand: Claude['command']['register'] = (command) => {
999
- for (const [key, commandName] of bound) {
1000
- if (commandName === command.name && command.immediate !== true) {
1001
- 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 })
1002
- }
1003
- }
1004
- return claude.command.register(command)
1005
- }
1006
- return { ...claude, command: { ...claude.command, register: addCommand } }
1056
+ function apiOf(name: string, returned: unknown): Readonly<Record<string, (input: never) => unknown>> {
1057
+ if (returned === undefined || returned === null) return {}
1058
+ if (typeof returned !== 'object' || !Object.values(returned).every((method) => typeof method === 'function')) throw new Error(`${name}: setup returns its API, an object of methods other mods call, or nothing. It returned ${typeof returned === 'object' ? 'an object with values that are not methods' : typeof returned}.`)
1059
+ return returned as Readonly<Record<string, (input: never) => unknown>>
1007
1060
  }
1008
1061
 
1009
1062
  const longestTimerMs = 2_147_483_647
@@ -1021,9 +1074,9 @@ function beforeAskOf(call: Frozen<Args<'cmod.call'>> & { readonly caller?: strin
1021
1074
  return { session, event, change, isInProject: isInProject !== false }
1022
1075
  }
1023
1076
 
1024
- async function failsAs(subject: string, step: () => unknown): Promise<void> {
1077
+ async function failsAs<Value>(subject: string, step: () => Value | Promise<Value>): Promise<Value> {
1025
1078
  try {
1026
- await step()
1079
+ return await step()
1027
1080
  } catch (error) {
1028
1081
  throw new Error(`${subject}: ${messageOf(error)}`, { cause: error })
1029
1082
  }