@cmodjs/core 4.0.2 → 4.1.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 (141) hide show
  1. package/docs/commands.md +43 -8
  2. package/docs/index.md +3 -3
  3. package/docs/install-steps.md +6 -6
  4. package/docs/jobs.md +2 -1
  5. package/docs/mod.md +1 -1
  6. package/docs/options.md +1 -1
  7. package/docs/permissions.md +1 -1
  8. package/docs/session.md +44 -13
  9. package/docs/testing.md +14 -10
  10. package/docs/ui.md +43 -11
  11. package/installer.d.ts +3 -2
  12. package/installer.d.ts.map +1 -1
  13. package/jobs/program.d.ts +1 -0
  14. package/jobs/program.d.ts.map +1 -1
  15. package/jobs/program.js +39 -23
  16. package/mod.d.ts +3 -2
  17. package/mod.d.ts.map +1 -1
  18. package/mod.js +1 -0
  19. package/package.json +1 -1
  20. package/records.d.ts +15 -1
  21. package/records.d.ts.map +1 -1
  22. package/records.js +39 -1
  23. package/register.d.ts.map +1 -1
  24. package/register.js +5 -2
  25. package/runtime/before.d.ts +6 -2
  26. package/runtime/before.d.ts.map +1 -1
  27. package/runtime/before.js +56 -18
  28. package/runtime/buttons.d.ts +10 -0
  29. package/runtime/buttons.d.ts.map +1 -0
  30. package/runtime/buttons.js +12 -0
  31. package/runtime/conversations.d.ts.map +1 -1
  32. package/runtime/conversations.js +134 -25
  33. package/runtime/engine.d.ts +2 -0
  34. package/runtime/engine.d.ts.map +1 -1
  35. package/runtime/grants.d.ts +1 -0
  36. package/runtime/grants.d.ts.map +1 -1
  37. package/runtime/grants.js +5 -2
  38. package/runtime/hotkeys.d.ts.map +1 -1
  39. package/runtime/hotkeys.js +7 -4
  40. package/runtime/installer.d.ts +9 -6
  41. package/runtime/installer.d.ts.map +1 -1
  42. package/runtime/installer.js +104 -47
  43. package/runtime/lifecycle.d.ts.map +1 -1
  44. package/runtime/lifecycle.js +121 -40
  45. package/runtime/pages.d.ts +3 -1
  46. package/runtime/pages.d.ts.map +1 -1
  47. package/runtime/pages.js +34 -10
  48. package/runtime/parts.d.ts +1 -0
  49. package/runtime/parts.d.ts.map +1 -1
  50. package/runtime/parts.js +6 -3
  51. package/runtime/programs.d.ts +1 -0
  52. package/runtime/programs.d.ts.map +1 -1
  53. package/runtime/programs.js +4 -1
  54. package/runtime/sessions.d.ts +2 -2
  55. package/runtime/sessions.d.ts.map +1 -1
  56. package/runtime/sessions.js +88 -58
  57. package/runtime/toaster.d.ts.map +1 -1
  58. package/runtime/toaster.js +23 -9
  59. package/runtime/ui.d.ts +21 -4
  60. package/runtime/ui.d.ts.map +1 -1
  61. package/runtime/ui.js +158 -58
  62. package/runtime/watches.d.ts.map +1 -1
  63. package/runtime/watches.js +18 -4
  64. package/runtime/widgets.d.ts +7 -5
  65. package/runtime/widgets.d.ts.map +1 -1
  66. package/runtime/widgets.js +29 -20
  67. package/session/context/turn/message/attached-file.d.ts +13 -0
  68. package/session/context/turn/message/attached-file.d.ts.map +1 -0
  69. package/session/context/turn/message/attached-file.js +21 -0
  70. package/session/context/turn/message/message.d.ts +3 -2
  71. package/session/context/turn/message/message.d.ts.map +1 -1
  72. package/session/context/turn/message/message.js +20 -6
  73. package/session/conversation.d.ts +14 -3
  74. package/session/conversation.d.ts.map +1 -1
  75. package/session/conversation.js +23 -4
  76. package/session/goal/task.d.ts +5 -0
  77. package/session/goal/task.d.ts.map +1 -1
  78. package/session/session.d.ts +35 -7
  79. package/session/session.d.ts.map +1 -1
  80. package/session/session.js +2 -1
  81. package/src/installer.ts +3 -2
  82. package/src/jobs/program.ts +41 -21
  83. package/src/mod.ts +3 -2
  84. package/src/records.ts +49 -1
  85. package/src/register.ts +9 -6
  86. package/src/runtime/before.ts +61 -22
  87. package/src/runtime/buttons.ts +22 -0
  88. package/src/runtime/conversations.ts +138 -32
  89. package/src/runtime/engine.ts +2 -0
  90. package/src/runtime/grants.ts +6 -2
  91. package/src/runtime/hotkeys.ts +7 -4
  92. package/src/runtime/installer.ts +144 -82
  93. package/src/runtime/lifecycle.ts +128 -43
  94. package/src/runtime/pages.ts +45 -8
  95. package/src/runtime/parts.ts +8 -4
  96. package/src/runtime/programs.ts +5 -1
  97. package/src/runtime/sessions.ts +83 -57
  98. package/src/runtime/toaster.ts +27 -9
  99. package/src/runtime/ui.ts +163 -61
  100. package/src/runtime/watches.ts +20 -5
  101. package/src/runtime/widgets.ts +34 -22
  102. package/src/session/context/turn/message/attached-file.ts +36 -0
  103. package/src/session/context/turn/message/message.ts +20 -7
  104. package/src/session/conversation.ts +30 -6
  105. package/src/session/goal/task.ts +6 -0
  106. package/src/session/session.ts +17 -7
  107. package/src/testing/fake-claude.ts +21 -6
  108. package/src/testing/fake-elements.ts +51 -8
  109. package/src/testing/fake-sessions.ts +60 -23
  110. package/src/testing.ts +86 -28
  111. package/src/ui/areas.ts +1 -0
  112. package/src/ui/components.ts +14 -8
  113. package/src/ui/elements.ts +6 -2
  114. package/src/ui/markdown.ts +3 -3
  115. package/src/ui/panes.ts +2 -2
  116. package/src/ui/styles.ts +6 -2
  117. package/testing/fake-claude.d.ts +3 -0
  118. package/testing/fake-claude.d.ts.map +1 -1
  119. package/testing/fake-claude.js +18 -5
  120. package/testing/fake-elements.d.ts.map +1 -1
  121. package/testing/fake-elements.js +46 -7
  122. package/testing/fake-sessions.d.ts +21 -10
  123. package/testing/fake-sessions.d.ts.map +1 -1
  124. package/testing/fake-sessions.js +58 -20
  125. package/testing.d.ts +8 -12
  126. package/testing.d.ts.map +1 -1
  127. package/testing.js +82 -21
  128. package/ui/areas.d.ts +4 -0
  129. package/ui/areas.d.ts.map +1 -1
  130. package/ui/components.d.ts +1 -0
  131. package/ui/components.d.ts.map +1 -1
  132. package/ui/components.js +11 -7
  133. package/ui/elements.d.ts +3 -0
  134. package/ui/elements.d.ts.map +1 -1
  135. package/ui/elements.js +6 -1
  136. package/ui/markdown.d.ts +2 -2
  137. package/ui/markdown.d.ts.map +1 -1
  138. package/ui/panes.d.ts +2 -1
  139. package/ui/panes.d.ts.map +1 -1
  140. package/ui/styles.d.ts +11 -2
  141. package/ui/styles.d.ts.map +1 -1
@@ -3,7 +3,7 @@ import type { Step } from '../installer.js'
3
3
 
4
4
  import type { Allowed, Imports, Mod, ModDefinition, Reservation, SettingsFile, SettingsFileChange, SettingsFileChangeListener, Subscription, Timer as ModTimer } from '../mod.js'
5
5
  import type { Options, OptionValues } from '../options.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
+ import { consentPath, dataFolder, finishStepsMethod, grantFix, coreVersion, isAtLeast, isProgramNewer, marketplaceOf, parseConsent, pendingStepsMethod, parseEvent, permissionWords, programPath, readRecord, readSteps, recordPath, scriptsSha256, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
7
7
  import { homePrefix, ifMissing, relativePath } from '../utils/paths.js'
8
8
  import { firstLine, formatExit, messageOf } from '../utils/text.js'
9
9
  import type { Claude } from './claude.js'
@@ -15,8 +15,8 @@ import { answerCall, answeringOwnCalls, cmodPluginName, dependencyCalls, withBar
15
15
  import { keepEngine } from './engine.js'
16
16
  import { classicFailure, classicHook, userSkillHook, type Hook, type HookFields, type HookListener, type RoutedEvent } from './hooks.js'
17
17
  import type { PermissionRoutes } from './permission-check.js'
18
- import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
19
- import { cmodError, locatingPrograms, readLines, type Programs } from './programs.js'
18
+ import { checkedAnswer, checkingGrants, checkRun, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
19
+ import { cmodError, locatingPrograms, readLines, spawnCmod, type Programs } from './programs.js'
20
20
  import { createModFiles, resolvedPath, type Places } from './metadata.js'
21
21
  import { fitsOption } from '../options.js'
22
22
  import { createInstaller } from './installer.js'
@@ -31,9 +31,9 @@ import { createToaster, type Failure, type ShownToast, type Toaster } from './to
31
31
  import { toolCalls } from './tool-calls.js'
32
32
  import { createHotkeys } from './hotkeys.js'
33
33
  import { servePages, type Pages } from './pages.js'
34
- import { drawnParts } from './parts.js'
34
+ import { drawnParts, type DrawnParts } from './parts.js'
35
35
  import { createWatches } from './watches.js'
36
- import { createUi, partMethods, type PartDraw, type PartsUpdate, type SettingsTab, type UiArea } from './ui.js'
36
+ import { createUi, partMethods, type BorrowedMarkdown, type PartDraw, type PartPress, type PartsUpdate, type SettingsTab, type ShownPages, type UiArea } from './ui.js'
37
37
  import { serveWidgets } from './widgets.js'
38
38
  import { Progress } from '../ui/components.js'
39
39
  import { Box, Text } from '../ui/elements.js'
@@ -73,6 +73,7 @@ type ModRuntime = {
73
73
  readonly refreshGrants: () => Promise<void>
74
74
  readonly permissionRoutes: () => PermissionRoutes | undefined
75
75
  readonly options: PluginOptions
76
+ readonly nextDraw: () => number
76
77
  }
77
78
 
78
79
  type ActiveMod<State extends object, Declared extends Options> = {
@@ -97,6 +98,10 @@ const installRetrySeconds = [2, 4, 8, 16, 32, 64, 128] as const
97
98
 
98
99
  const cmodCheckMs = 1000
99
100
 
101
+ const recordCheckMs = 1000
102
+
103
+ const drawsPerMs = 1000
104
+
100
105
  const cmodWaitMs = 60_000
101
106
 
102
107
  const cmodSlowCheckMs = 10_000
@@ -135,7 +140,8 @@ export async function readPlugin(claude: Claude): Promise<Plugin> {
135
140
  })
136
141
  if (version === undefined || record?.scriptsSha256 !== scripts) return { ...plugin, isInstalled: false, shouldRecord: false }
137
142
  const isInstalled = record.version === version || record.root === root
138
- return { ...plugin, isInstalled, shouldRecord: isInstalled && record.version !== version }
143
+ const programModifiedMs = record.program === null ? undefined : (await claude.fs.stat(programPath(store, record.program, record.version)).catch(ifMissing(undefined)))?.mtimeMs
144
+ return { ...plugin, isInstalled, shouldRecord: isInstalled && (record.version !== version || isProgramNewer(record, programModifiedMs)) }
139
145
  }
140
146
 
141
147
  async function filesBelow(claude: Claude, folder: string): Promise<string[]> {
@@ -193,13 +199,20 @@ export function createLifecycle<State extends object, Declared extends Options =
193
199
  let raise: SessionsClient['raise'] | undefined
194
200
  let failure: unknown
195
201
  let pluginOptions = options
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 } })
202
+ let drawCount = 0
203
+ const nextDraw = () => (drawCount += 1)
204
+ const installer = createInstaller(
205
+ definition.name,
206
+ () => claude(),
207
+ {
208
+ show: (open) => {
209
+ if (phase !== 'active') runtime?.toaster.toast.dismiss(installId)
210
+ runtime?.toaster.toast.info('Installing', { id: askingId, description: 'Waiting for your answer', duration: Infinity, action: { label: 'Answer', onClick: open } })
211
+ },
212
+ hide: () => runtime?.toaster.toast.dismiss(askingId),
200
213
  },
201
- hide: () => runtime?.toaster.toast.dismiss(askingId),
202
- })
214
+ nextDraw,
215
+ )
203
216
  let shouldRecord = false
204
217
  let recording: Promise<void> | undefined
205
218
  let missedSessionStart: Frozen<Args<'classic.SessionStart'>> | undefined
@@ -221,6 +234,7 @@ export function createLifecycle<State extends object, Declared extends Options =
221
234
  failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'}${fix === undefined ? '' : ` ${fix}`}`, { cause: error })
222
235
  const again = retry === undefined ? {} : { retry: () => ((failure = undefined), stopRetrying(), retry().catch(report)) }
223
236
  toastFailure({ id: installId, message: reason, ...(fix === undefined ? {} : { description: fix }), ...(error === undefined ? {} : { error }), ...again })
237
+ watchRecord()
224
238
  }
225
239
 
226
240
  let consented: string | undefined
@@ -332,27 +346,31 @@ export function createLifecycle<State extends object, Declared extends Options =
332
346
 
333
347
  const runSteps = async (active: Mod<State>) => {
334
348
  const steps = asksPerson ? (definition.installer ?? []) : []
335
- for (const [index, step] of steps.entries()) {
336
- if (await isStepDone(active, step)) continue
337
- const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length))
338
- if (!isAnswered) {
339
- runtime?.toaster.toast.warning(steps.length - index === 1 ? 'Needs one more step' : `Needs ${steps.length - index} more steps`, {
340
- id: 'cmod:steps',
341
- description: `Starting with ${step.title}.`,
342
- duration: Infinity,
343
- action: { label: 'Finish', onClick: () => runSteps(active) },
344
- })
345
- break
349
+ try {
350
+ for (const [index, step] of steps.entries()) {
351
+ if (await isStepDone(active, step)) continue
352
+ const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length))
353
+ if (!isAnswered) {
354
+ runtime?.toaster.toast.warning(steps.length - index === 1 ? 'Needs one more step' : `Needs ${steps.length - index} more steps`, {
355
+ id: 'cmod:steps',
356
+ description: `Starting with ${step.title}.`,
357
+ duration: Infinity,
358
+ action: { label: 'Finish', onClick: () => runSteps(active) },
359
+ })
360
+ break
361
+ }
346
362
  }
363
+ } finally {
364
+ await installer.close()
347
365
  }
348
- await installer.close()
349
366
  }
350
367
 
351
368
  const activate = async (): Promise<void> => {
352
369
  if (runtime === undefined || plugin === undefined) return
353
370
  const activeRouter = createRouter()
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) => {
371
+ 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, nextDraw }).catch(async (error: unknown) => {
355
372
  if (error instanceof MissingOptions && (await askOptions(error))) return undefined
373
+ await installer.close()
356
374
  fail(messageOf(error), error instanceof MissingOptions ? `Retry asks for ${error.keys.length === 1 ? 'it' : 'them'}.` : undefined, begin, error)
357
375
  throw error
358
376
  })
@@ -363,6 +381,7 @@ export function createLifecycle<State extends object, Declared extends Options =
363
381
  raise = active.raise
364
382
  router = activeRouter
365
383
  phase = 'active'
384
+ stopWatchingRecord()
366
385
  runtime.claude.ui.invalidate('ui.render')
367
386
  void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
368
387
  const missed = missedSessionStart
@@ -418,9 +437,14 @@ export function createLifecycle<State extends object, Declared extends Options =
418
437
  toastFailure({ id: 'cmod:updates', message: 'Could not read whether its marketplace updates automatically', error })
419
438
  return undefined
420
439
  })
421
- const isAccepted = await installer.consent({ ...event, updates })
440
+ const installedElsewhere = new Promise<'installed'>((resolve) => (settleAsked = () => resolve('installed')))
441
+ watchRecord()
442
+ const answer = await Promise.race([installer.consent({ ...event, updates }), installedElsewhere]).finally(() => {
443
+ settleAsked = undefined
444
+ })
422
445
  await installer.close()
423
- if (isAccepted) {
446
+ if (answer === 'installed') return startInstalled()
447
+ if (answer) {
424
448
  consented = event.sha256
425
449
  return install(consented)
426
450
  }
@@ -544,20 +568,45 @@ export function createLifecycle<State extends object, Declared extends Options =
544
568
  }
545
569
  let recordMs = 0
546
570
  let readStarted: ((claude: Claude) => Promise<Plugin>) | undefined
547
- const recover = async (): Promise<boolean> => {
548
- if (plugin === undefined || readStarted === undefined || (phase !== 'failed' && phase !== 'declined')) return false
571
+ const isNewlyInstalled = async (): Promise<boolean> => {
572
+ if (plugin === undefined || readStarted === undefined) return false
549
573
  const modifiedMs = await stampOf(recordPath(plugin.store, plugin.name))
550
574
  if (modifiedMs === recordMs) return false
551
575
  recordMs = modifiedMs
552
576
  const current = await readStarted(claude())
553
577
  if (!current.isInstalled) return false
554
578
  plugin = current
579
+ return true
580
+ }
581
+ const startInstalled = async () => {
555
582
  failure = undefined
556
583
  activation = undefined
557
584
  await refreshGrants()
558
585
  await finish()
586
+ }
587
+ let settleAsked: (() => void) | undefined
588
+ const recover = async (): Promise<boolean> => {
589
+ if ((settleAsked === undefined && phase !== 'failed' && phase !== 'declined') || !(await isNewlyInstalled())) return false
590
+ if (settleAsked === undefined) await startInstalled()
591
+ else settleAsked()
559
592
  return true
560
593
  }
594
+ let recordCheck: Timer | undefined
595
+ let checking: Promise<void> | undefined
596
+ const watchRecord = () => {
597
+ recordCheck ??= claude().clock.every(recordCheckMs, () => {
598
+ checking ??= recover()
599
+ .then(() => undefined)
600
+ .catch((error: unknown) => claude().ui.log(`${definition.name} could not read whether another cmod installed it: ${messageOf(error)}`, { to: 'debug' }))
601
+ .finally(() => {
602
+ checking = undefined
603
+ })
604
+ })
605
+ }
606
+ const stopWatchingRecord = () => {
607
+ recordCheck?.cancel()
608
+ recordCheck = undefined
609
+ }
561
610
 
562
611
  const refreshGrants = async () => {
563
612
  if (plugin === undefined || plugin.name === cmodPluginName) return
@@ -639,6 +688,7 @@ export function createLifecycle<State extends object, Declared extends Options =
639
688
  runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name, () => plugin?.version, () => openSettings('permissions')) }
640
689
  readStarted = read
641
690
  try {
691
+ drawCount = (await claudeCalls.clock.now()) * drawsPerMs
642
692
  await programs.refresh()
643
693
  await begin().catch(report)
644
694
  } finally {
@@ -647,8 +697,13 @@ export function createLifecycle<State extends object, Declared extends Options =
647
697
  },
648
698
  async route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
649
699
  if (imports !== undefined) bind(imports)
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>
651
- if (event === 'ui.close') installer.closed((e as Frozen<Args<'ui.close'>>).id)
700
+ const asked = e as Frozen<Args<'cmod.call'>> & { readonly caller?: string }
701
+ if (event === 'cmod.call' && asked.to === definition.name && asked.caller === cmodPluginName) {
702
+ if (asked.method === partMethods.shown) installer.hear(asked.input as ShownPages)
703
+ const answered = installer.answer(asked.method, asked.input)
704
+ if (answered !== undefined) return (await answered.catch((error: unknown) => ({ deny: `${definition.name}: ${messageOf(error)}` }))) as EventResult<N>
705
+ if (asked.method === partMethods.shown && phase !== 'active') return { value: null } as EventResult<N>
706
+ }
652
707
  if (event === 'classic.SessionStart') {
653
708
  missedSessionStart = undefined
654
709
  hasHeardSessionStart = true
@@ -659,9 +714,10 @@ export function createLifecycle<State extends object, Declared extends Options =
659
714
  }
660
715
  if (shouldRecord && event === 'classic.UserPromptSubmit') record()
661
716
  if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart') await recover().catch((error: unknown) => claude().ui.log(`${definition.name} stays stopped: ${messageOf(error)}`, { to: 'debug' }))
662
- const isCallToThisMod = event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name
717
+ const tool = (e as { readonly tool?: unknown }).tool
718
+ const isCallToThisMod = (event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name) || (event === 'tool.call' && typeof tool === 'string' && tool.startsWith(`mcp__${definition.name}__`))
663
719
  if (event === 'cmod.call' && !isCallToThisMod) return next(e)
664
- await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
720
+ await (event === 'classic.SessionStart' || event === 'session.append' || isCallToThisMod ? holdUntilStarted() : whenActive())
665
721
  if (event === 'classic.SessionStart' && phase !== 'active') {
666
722
  missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
667
723
  return next(e)
@@ -673,7 +729,11 @@ export function createLifecycle<State extends object, Declared extends Options =
673
729
  const call = e as Frozen<Args<'cmod.call'>>
674
730
  if (event === 'cmod.call' && call.to === definition.name && mod !== undefined) {
675
731
  if (call.method === pendingStepsMethod) return { value: await pendingSteps(mod) } as EventResult<N>
676
- if (call.method === finishStepsMethod) return (await runSteps(mod), { value: null }) as EventResult<N>
732
+ if (call.method === finishStepsMethod) {
733
+ const active = mod
734
+ const opened = await installer.collect(() => Promise.race([runSteps(active).catch((error: unknown) => void toastFailure({ id: 'cmod:steps', message: 'Could not finish the setup', error })), installer.shown()]))
735
+ return { value: opened } as EventResult<N>
736
+ }
677
737
  }
678
738
  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
739
  if (event === 'classic.UserPromptSubmit' && phase === 'active' && !hasHeardSessionStart) {
@@ -737,7 +797,13 @@ async function createMod<State extends object, Declared extends Options>(definit
737
797
  let parentAsked: Promise<string | undefined> | undefined
738
798
  let hasEarlierPrompts = true
739
799
  let agentType: string | undefined
740
- const area = createUi<State>({ name: definition.name, claude, router, report, announce, mod: () => mod })
800
+ let parts: DrawnParts | undefined
801
+ const borrowed: BorrowedMarkdown = (styler, cached, input) => {
802
+ const name = `The markdown style of ${styler}`
803
+ void parts?.fill(styler, cached, input, name)?.then(() => claude.ui.invalidate('ui.render'))
804
+ return parts?.element(styler, cached, `${styler}/markdown:`, name, `markdown:${styler}`)
805
+ }
806
+ const area = createUi<State>({ name: definition.name, claude, router, report, announce, mod: () => mod, borrowed, nextDraw: runtime.nextDraw })
741
807
  const askParent = async (): Promise<string | undefined> => {
742
808
  if (definition.name === cmodPluginName) return undefined
743
809
  try {
@@ -1003,9 +1069,15 @@ async function createMod<State extends object, Declared extends Options>(definit
1003
1069
  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
1070
  let pages: Pages | undefined
1005
1071
  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 })
1072
+ const pressElsewhere = (to: string, input: PartPress) =>
1073
+ void claude.cmod.call({ to, method: partMethods.press, input }).catch((error: unknown) => {
1074
+ parts?.forget(to)
1075
+ claude.ui.invalidate('ui.render')
1076
+ reportPart(`press:${to}`, `${to} did not take the press`, error)
1077
+ })
1078
+ parts = drawnParts({ claude, report: reportPart, press: (to, input) => (pages === undefined ? pressElsewhere(to, input) : pages.press(to, input)) })
1079
+ const widgets = conversations === undefined ? undefined : serveWidgets({ claude, router, store: runtime.store, report: reportPart, turnClosedBy: (rowId) => conversations.turnClosedBy(rowId), parts, markdownIn: area.markdownIn })
1080
+ pages = widgets === undefined ? undefined : servePages({ claude, router, report: reportPart, widgets, parts, tellStylers: area.restyled })
1009
1081
  router.add('cmod.call', async (e, next) => {
1010
1082
  if (e.to !== definition.name) return next(e)
1011
1083
  const call = e as typeof e & { readonly caller?: string }
@@ -1018,12 +1090,12 @@ async function createMod<State extends object, Declared extends Options>(definit
1018
1090
  const ask = beforeAskOf(call)
1019
1091
  if (ask !== undefined) return { value: (await client.answerBefore(ask.session, ask.event, ask.change, ask.isInProject)) ?? null }
1020
1092
  if (e.method === partMethods.changed && call.caller !== undefined && pages !== undefined) {
1021
- return { value: { isHeld: pages.update(call.caller, e.input as PartsUpdate) } }
1093
+ return { value: { isHeld: pages.update(call.caller, e.input as PartsUpdate), stylers: pages.stylers() } }
1022
1094
  }
1023
- if (call.caller === cmodPluginName) {
1095
+ if (call.caller !== undefined) {
1024
1096
  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
1097
+ const answered = area.answer(e.method, turn === undefined ? e.input : { ...(e.input as PartDraw), turn: client.turn(turn) }, call.caller)
1098
+ if (answered !== undefined) return answered.catch((error: unknown) => ({ deny: `${definition.name}: ${messageOf(error)}` }))
1027
1099
  }
1028
1100
  const served = await conversations?.answer(call)
1029
1101
  return served ?? answerCall(definition.name, api, e)
@@ -1033,7 +1105,20 @@ async function createMod<State extends object, Declared extends Options>(definit
1033
1105
  return ask === undefined ? { deny: `${definition.name}: ${error.message}` } : { value: client.failedBefore(ask.session, ask.event, ask.change, ask.isInProject, error) ?? null }
1034
1106
  })
1035
1107
  const agents = toolCalls(claude, router)
1036
- keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined, answerInput: area.answerInput, ...(widgets === undefined ? {} : { widgets }), ...(pages === undefined ? {} : { pages }) })
1108
+ keepEngine(mod, {
1109
+ claude: checked,
1110
+ router,
1111
+ toolCalls: agents,
1112
+ report,
1113
+ hasPermissionCheck: () => runtime.permissionRoutes() !== undefined,
1114
+ answerInput: area.answerInput,
1115
+ spawnCmod: async (args, program) => {
1116
+ await checkRun(grants, 'process.spawn', program.argv)
1117
+ return spawnCmod(claude, args, session, program.env)
1118
+ },
1119
+ ...(widgets === undefined ? {} : { widgets }),
1120
+ ...(pages === undefined ? {} : { pages }),
1121
+ })
1037
1122
  const names = new Set<string>()
1038
1123
  const reserveName = (type: string, name: string, taken: string): Reservation => {
1039
1124
  const key = `${type}:${name}`
@@ -1,7 +1,9 @@
1
1
  import type { RenderElement } from 'claude-code'
2
2
  import { isObject } from '../records.js'
3
+ import { Text } from '../ui/elements.js'
3
4
  import { messageOf } from '../utils/text.js'
4
5
  import type { Claude } from './claude.js'
6
+ import { cmodPluginName } from './dependencies.js'
5
7
  import type { DrawnParts } from './parts.js'
6
8
  import type { Router } from './router.js'
7
9
  import { partMethods, type PageDeclaration, type PageRequest, type PartPress, type PartsUpdate, type ShownPages } from './ui.js'
@@ -15,6 +17,7 @@ export type PageApply = (mod: string, requests: readonly PageRequest[]) => Promi
15
17
 
16
18
  export type Pages = {
17
19
  declared(): readonly ModPages[]
20
+ stylers(): readonly string[]
18
21
  element(mod: string, page: string, params: unknown, columns: number, isFocused: boolean): RenderElement | undefined
19
22
  press(mod: string, press: PartPress): void
20
23
  update(mod: string, update: PartsUpdate): boolean
@@ -27,8 +30,23 @@ export function pagesOf(input: unknown): PageDeclaration[] {
27
30
  return listed.filter(isObject).flatMap((each) => (typeof each['id'] === 'string' && typeof each['title'] === 'string' && (each['place'] === 'sidebar' || each['place'] === 'settings') ? [{ id: each['id'], title: each['title'], place: each['place'] }] : []))
28
31
  }
29
32
 
30
- export function servePages({ claude, router, report, widgets, parts }: { readonly claude: Claude; readonly router: Router; readonly report: (id: string, message: string, error: unknown) => void; readonly widgets: Pick<Widgets, 'update'>; readonly parts: DrawnParts }): Pages {
33
+ export function servePages({
34
+ claude,
35
+ router,
36
+ report,
37
+ widgets,
38
+ parts,
39
+ tellStylers,
40
+ }: {
41
+ readonly claude: Claude
42
+ readonly router: Router
43
+ readonly report: (id: string, message: string, error: unknown) => void
44
+ readonly widgets: Pick<Widgets, 'update'>
45
+ readonly parts: DrawnParts
46
+ readonly tellStylers: (stylers: readonly string[]) => void
47
+ }): Pages {
31
48
  const declared = new Map<string, readonly PageDeclaration[]>()
49
+ const stylers = new Set<string>()
32
50
  const asking = new Map<string, Promise<void>>()
33
51
  const pressing = new Set<Promise<void>>()
34
52
  let waiting: { readonly mod: string; readonly requests: readonly PageRequest[] }[] = []
@@ -44,16 +62,18 @@ export function servePages({ claude, router, report, widgets, parts }: { readonl
44
62
  for (const { mod, requests } of taken) await run(mod, requests)
45
63
  }
46
64
 
47
- const settle = async () => {
48
- while (pressing.size > 0) await Promise.allSettled(pressing)
65
+ const settle = async (earlier: ReadonlySet<Promise<void>>) => {
66
+ const own = () => [...pressing].filter((pending) => !earlier.has(pending))
67
+ for (let pending = own(); pending.length > 0; pending = own()) await Promise.allSettled(pending)
49
68
  await takeWaiting()
50
69
  }
51
70
 
52
71
  const wrapped = async <Answer>(next: () => Promise<Answer>): Promise<Answer> => {
53
72
  inputDepth += 1
73
+ const earlier = new Set(pressing)
54
74
  try {
55
75
  const answer = await next()
56
- await settle()
76
+ await settle(earlier)
57
77
  return answer
58
78
  } finally {
59
79
  inputDepth -= 1
@@ -77,9 +97,22 @@ export function servePages({ claude, router, report, widgets, parts }: { readonl
77
97
  return pending
78
98
  }
79
99
 
100
+ const restyle = (mod: string, isStyler: boolean) => {
101
+ if (!isStyler && !stylers.delete(mod)) return
102
+ if (isStyler) stylers.add(mod)
103
+ parts.forget(mod)
104
+ const listed = [...stylers]
105
+ tellStylers(listed)
106
+ for (const told of declared.keys()) {
107
+ if (told !== cmodPluginName) void claude.cmod.call({ to: told, method: partMethods.stylers, input: listed }).catch((error: unknown) => claude.ui.log(`cmod: ${told} did not hear which mods style markdown: ${messageOf(error)}`, { to: 'debug' }))
108
+ }
109
+ }
110
+
80
111
  const update = (mod: string, { parts: listing, redraw, requests, isAsked }: PartsUpdate) => {
81
112
  if (listing !== undefined) declared.set(mod, pagesOf(listing))
82
- if (listing !== undefined || redraw === true) widgets.update(mod, { ...(listing === undefined ? {} : { parts: listing }), ...(redraw === undefined ? {} : { redraw }) })
113
+ if (listing !== undefined) restyle(mod, isObject(listing) && listing['stylesMarkdown'] === true)
114
+ const isRedrawn = redraw === true || requests?.some((request) => 'open' in request) === true
115
+ if (listing !== undefined || isRedrawn) widgets.update(mod, { ...(listing === undefined ? {} : { parts: listing }), ...(isRedrawn ? { redraw: true } : {}) })
83
116
  if (requests === undefined || requests.length === 0) return false
84
117
  if (isAsked !== true) return (void run(mod, requests), false)
85
118
  waiting.push({ mod, requests })
@@ -106,19 +139,23 @@ export function servePages({ claude, router, report, widgets, parts }: { readonl
106
139
 
107
140
  const cacheKey = (page: string, params: unknown, columns: number, isFocused: boolean) => `${page}\u0000${JSON.stringify(params ?? null)}\u0000${columns}\u0000${isFocused}`
108
141
 
142
+ const plain = <Data,>(data: Data): Data => JSON.parse(JSON.stringify(data)) as Data
143
+
109
144
  return {
110
145
  declared: () => [...declared].map(([mod, pages]) => ({ mod, pages })),
146
+ stylers: () => [...stylers],
111
147
  element(mod, page, params, columns, isFocused) {
112
148
  if (!declared.has(mod)) void ask(mod)
113
149
  const cached = cacheKey(page, params, columns, isFocused)
114
150
  const name = `The ${page} page of ${mod}`
115
- void parts.fill(mod, cached, { page, params: params ?? null, columns, isFocused }, name)?.then(() => claude.ui.invalidate('ui.render'))
116
- return parts.element(mod, cached, `${mod}/${page}:`, name, `page:${mod}/${page}`)
151
+ void parts.fill(mod, cached, { page, params: plain(params ?? null), columns, isFocused }, name)?.then(() => claude.ui.invalidate('ui.render'))
152
+ const failure = parts.failure(mod, cached)
153
+ return parts.element(mod, cached, `${mod}/${page}:`, name, `page:${mod}/${page}`) ?? (failure === undefined ? undefined : Text({ color: 'error', children: `${name} could not draw: ${failure}` }))
117
154
  },
118
155
  press,
119
156
  update,
120
157
  tell(mod, shown) {
121
- void claude.cmod.call({ to: mod, method: partMethods.shown, input: shown }).catch((error: unknown) => claude.ui.log(`cmod: ${mod} did not hear which of its pages show: ${messageOf(error)}`, { to: 'debug' }))
158
+ void claude.cmod.call({ to: mod, method: partMethods.shown, input: plain(shown) }).catch((error: unknown) => claude.ui.log(`cmod: ${mod} did not hear which of its pages show: ${messageOf(error)}`, { to: 'debug' }))
122
159
  },
123
160
  apply(listener) {
124
161
  applyTo = listener
@@ -7,10 +7,11 @@ import { partMethods, type DrawnPart, type PartPress } from './ui.js'
7
7
  export type DrawnParts = {
8
8
  fill(mod: string, cached: string, input: Readonly<Record<string, unknown>>, name: string): Promise<void> | undefined
9
9
  element(mod: string, cached: string, keyPrefix: string, name: string, reportId: string): RenderElement | undefined
10
+ failure(mod: string, cached: string): string | undefined
10
11
  forget(mod: string): void
11
12
  }
12
13
 
13
- type Drawn = { readonly data: DrawnPart | null; readonly generation: number }
14
+ type Drawn = { readonly data: DrawnPart | null; readonly failure?: string; readonly generation: number }
14
15
 
15
16
  const keptDrawings = 200
16
17
 
@@ -26,9 +27,9 @@ export function drawnParts({ claude, report, press }: { readonly claude: Claude;
26
27
  const key = keyOf(mod, cached)
27
28
  const generation = generationOf(mod)
28
29
  if (drawn.get(key)?.generation === generation) return undefined
29
- const keep = (data: DrawnPart | null) => {
30
+ const keep = (data: DrawnPart | null, failure?: string) => {
30
31
  drawn.delete(key)
31
- drawn.set(key, { data, generation })
32
+ drawn.set(key, { data, ...(failure === undefined ? {} : { failure }), generation })
32
33
  const [oldest] = drawn.keys()
33
34
  if (drawn.size > keptDrawings && oldest !== undefined) drawn.delete(oldest)
34
35
  if (generationOf(mod) !== generation) claude.ui.invalidate('ui.render')
@@ -41,7 +42,7 @@ export function drawnParts({ claude, report, press }: { readonly claude: Claude;
41
42
  (data) => keep((data ?? null) as DrawnPart | null),
42
43
  (error: unknown) => {
43
44
  claude.ui.log(`cmod: ${name} drew nothing: ${messageOf(error)}`, { to: 'debug' })
44
- keep(null)
45
+ keep(drawn.get(key)?.data ?? null, messageOf(error))
45
46
  },
46
47
  )
47
48
  .finally(() => drawing.delete(key))
@@ -63,6 +64,9 @@ export function drawnParts({ claude, report, press }: { readonly claude: Claude;
63
64
  return undefined
64
65
  }
65
66
  },
67
+ failure(mod, cached) {
68
+ return drawn.get(keyOf(mod, cached))?.failure
69
+ },
66
70
  forget(mod) {
67
71
  generations.set(mod, generationOf(mod) + 1)
68
72
  },
@@ -35,9 +35,13 @@ export async function readLines(stream: HookStream<ProcessSpawnChunk, ProcessSpa
35
35
  return { code: piece.value.code, lastError }
36
36
  }
37
37
 
38
+ export function spawnCmod(claude: Claude, args: readonly string[], session: string, env: Readonly<Record<string, string>> = {}): HookStream<ProcessSpawnChunk, ProcessSpawnResult> {
39
+ return claude.process.spawn({ argv: ['cmod', ...args], env: { ...env, CLAUDE_CODE_SESSION_ID: session } })
40
+ }
41
+
38
42
  export async function runCmod(claude: Claude, args: readonly string[], session: string): Promise<string> {
39
43
  const lines: string[] = []
40
- const { code, lastError } = await readLines(claude.process.spawn({ argv: ['cmod', ...args], env: { CLAUDE_CODE_SESSION_ID: session } }), (line) => void lines.push(line))
44
+ const { code, lastError } = await readLines(spawnCmod(claude, args, session), (line) => void lines.push(line))
41
45
  if (code !== 0) throw new Error(cmodError(lastError, args[0]) || `cmod ${args.slice(0, 2).join(' ')} ${formatExit(code, '')}.`)
42
46
  return lines.join('\n')
43
47
  }