@cmodjs/core 4.0.3 → 4.1.1

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 (143) hide show
  1. package/docs/commands.md +44 -9
  2. package/docs/index.md +3 -3
  3. package/docs/install-steps.md +8 -8
  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 +46 -13
  9. package/docs/testing.md +15 -11
  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 +8 -5
  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 +3 -0
  32. package/runtime/conversations.d.ts.map +1 -1
  33. package/runtime/conversations.js +164 -27
  34. package/runtime/engine.d.ts +2 -0
  35. package/runtime/engine.d.ts.map +1 -1
  36. package/runtime/grants.d.ts +1 -0
  37. package/runtime/grants.d.ts.map +1 -1
  38. package/runtime/grants.js +5 -2
  39. package/runtime/hotkeys.d.ts.map +1 -1
  40. package/runtime/hotkeys.js +7 -4
  41. package/runtime/installer.d.ts +10 -6
  42. package/runtime/installer.d.ts.map +1 -1
  43. package/runtime/installer.js +106 -48
  44. package/runtime/lifecycle.d.ts +2 -2
  45. package/runtime/lifecycle.d.ts.map +1 -1
  46. package/runtime/lifecycle.js +144 -42
  47. package/runtime/pages.d.ts +3 -1
  48. package/runtime/pages.d.ts.map +1 -1
  49. package/runtime/pages.js +48 -14
  50. package/runtime/parts.d.ts +1 -0
  51. package/runtime/parts.d.ts.map +1 -1
  52. package/runtime/parts.js +6 -3
  53. package/runtime/programs.d.ts +1 -0
  54. package/runtime/programs.d.ts.map +1 -1
  55. package/runtime/programs.js +4 -1
  56. package/runtime/sessions.d.ts +10 -3
  57. package/runtime/sessions.d.ts.map +1 -1
  58. package/runtime/sessions.js +104 -59
  59. package/runtime/toaster.d.ts.map +1 -1
  60. package/runtime/toaster.js +23 -9
  61. package/runtime/ui.d.ts +22 -4
  62. package/runtime/ui.d.ts.map +1 -1
  63. package/runtime/ui.js +161 -59
  64. package/runtime/watches.d.ts.map +1 -1
  65. package/runtime/watches.js +18 -4
  66. package/runtime/widgets.d.ts +7 -5
  67. package/runtime/widgets.d.ts.map +1 -1
  68. package/runtime/widgets.js +31 -21
  69. package/session/context/turn/message/attached-file.d.ts +13 -0
  70. package/session/context/turn/message/attached-file.d.ts.map +1 -0
  71. package/session/context/turn/message/attached-file.js +21 -0
  72. package/session/context/turn/message/message.d.ts +3 -2
  73. package/session/context/turn/message/message.d.ts.map +1 -1
  74. package/session/context/turn/message/message.js +20 -6
  75. package/session/conversation.d.ts +15 -3
  76. package/session/conversation.d.ts.map +1 -1
  77. package/session/conversation.js +23 -4
  78. package/session/goal/task.d.ts +5 -0
  79. package/session/goal/task.d.ts.map +1 -1
  80. package/session/session.d.ts +36 -7
  81. package/session/session.d.ts.map +1 -1
  82. package/session/session.js +2 -1
  83. package/src/installer.ts +3 -2
  84. package/src/jobs/program.ts +41 -21
  85. package/src/mod.ts +3 -2
  86. package/src/records.ts +49 -1
  87. package/src/register.ts +11 -8
  88. package/src/runtime/before.ts +61 -22
  89. package/src/runtime/buttons.ts +22 -0
  90. package/src/runtime/conversations.ts +170 -34
  91. package/src/runtime/engine.ts +2 -0
  92. package/src/runtime/grants.ts +6 -2
  93. package/src/runtime/hotkeys.ts +7 -4
  94. package/src/runtime/installer.ts +147 -83
  95. package/src/runtime/lifecycle.ts +149 -48
  96. package/src/runtime/pages.ts +56 -12
  97. package/src/runtime/parts.ts +8 -4
  98. package/src/runtime/programs.ts +5 -1
  99. package/src/runtime/sessions.ts +106 -59
  100. package/src/runtime/toaster.ts +27 -9
  101. package/src/runtime/ui.ts +166 -62
  102. package/src/runtime/watches.ts +20 -5
  103. package/src/runtime/widgets.ts +35 -23
  104. package/src/session/context/turn/message/attached-file.ts +36 -0
  105. package/src/session/context/turn/message/message.ts +20 -7
  106. package/src/session/conversation.ts +31 -6
  107. package/src/session/goal/task.ts +6 -0
  108. package/src/session/session.ts +18 -7
  109. package/src/testing/fake-claude.ts +21 -6
  110. package/src/testing/fake-elements.ts +57 -8
  111. package/src/testing/fake-sessions.ts +72 -24
  112. package/src/testing.ts +114 -33
  113. package/src/ui/areas.ts +1 -0
  114. package/src/ui/components.ts +14 -8
  115. package/src/ui/elements.ts +6 -2
  116. package/src/ui/markdown.ts +3 -3
  117. package/src/ui/panes.ts +2 -2
  118. package/src/ui/styles.ts +6 -2
  119. package/testing/fake-claude.d.ts +3 -0
  120. package/testing/fake-claude.d.ts.map +1 -1
  121. package/testing/fake-claude.js +18 -5
  122. package/testing/fake-elements.d.ts.map +1 -1
  123. package/testing/fake-elements.js +52 -7
  124. package/testing/fake-sessions.d.ts +24 -10
  125. package/testing/fake-sessions.d.ts.map +1 -1
  126. package/testing/fake-sessions.js +66 -20
  127. package/testing.d.ts +8 -12
  128. package/testing.d.ts.map +1 -1
  129. package/testing.js +113 -27
  130. package/ui/areas.d.ts +4 -0
  131. package/ui/areas.d.ts.map +1 -1
  132. package/ui/components.d.ts +1 -0
  133. package/ui/components.d.ts.map +1 -1
  134. package/ui/components.js +11 -7
  135. package/ui/elements.d.ts +3 -0
  136. package/ui/elements.d.ts.map +1 -1
  137. package/ui/elements.js +6 -1
  138. package/ui/markdown.d.ts +2 -2
  139. package/ui/markdown.d.ts.map +1 -1
  140. package/ui/panes.d.ts +2 -1
  141. package/ui/panes.d.ts.map +1 -1
  142. package/ui/styles.d.ts +11 -2
  143. package/ui/styles.d.ts.map +1 -1
@@ -1,9 +1,9 @@
1
- import type { Args, ClassicHookInputs, EventResult, Frozen, HookBudget, HookFailure, PluginOptions, RenderElement, Timer } from 'claude-code'
1
+ import type { Args, Caught, ClassicHookInputs, EventResult, Frozen, HookBudget, PluginOptions, RenderElement, Timer } from 'claude-code'
2
2
  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,14 +15,14 @@ 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'
23
23
  import { createOptions, MissingOptions } from './options.js'
24
24
  import { createRouter, type Router, type RouterNext } from './router.js'
25
- import { conversationMethods, createSessionsClient, type SessionsClient } from './sessions.js'
25
+ import { conversationMethods, createSessionsClient, type SessionColor, type SessionsClient } from './sessions.js'
26
26
  import type { SessionChange, TurnData } from '../session/conversation.js'
27
27
  import type { BeforeEvent } from '../session/session.js'
28
28
  import { matches } from '../utils/matcher.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'
@@ -58,7 +58,7 @@ export type Lifecycle<State extends object> = {
58
58
  readonly toasts: readonly ShownToast[]
59
59
  start(claude: Claude, read: (claude: Claude) => Promise<Plugin>): Promise<void>
60
60
  route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
61
- caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, failure: HookFailure): Promise<EventResult<N>>
61
+ caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, failure: Caught): Promise<EventResult<N>>
62
62
  raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
63
63
  }
64
64
 
@@ -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,7 +381,9 @@ 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')
386
+ if (definition.name === cmodPluginName) runtime.claude.ui.invalidate('command.describe')
367
387
  void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
368
388
  const missed = missedSessionStart
369
389
  missedSessionStart = undefined
@@ -418,9 +438,14 @@ export function createLifecycle<State extends object, Declared extends Options =
418
438
  toastFailure({ id: 'cmod:updates', message: 'Could not read whether its marketplace updates automatically', error })
419
439
  return undefined
420
440
  })
421
- const isAccepted = await installer.consent({ ...event, updates })
441
+ const installedElsewhere = new Promise<'installed'>((resolve) => (settleAsked = () => resolve('installed')))
442
+ watchRecord()
443
+ const answer = await Promise.race([installer.consent({ ...event, program: plugin?.steps.program ?? '', updates }), installedElsewhere]).finally(() => {
444
+ settleAsked = undefined
445
+ })
422
446
  await installer.close()
423
- if (isAccepted) {
447
+ if (answer === 'installed') return startInstalled()
448
+ if (answer) {
424
449
  consented = event.sha256
425
450
  return install(consented)
426
451
  }
@@ -544,20 +569,45 @@ export function createLifecycle<State extends object, Declared extends Options =
544
569
  }
545
570
  let recordMs = 0
546
571
  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
572
+ const isNewlyInstalled = async (): Promise<boolean> => {
573
+ if (plugin === undefined || readStarted === undefined) return false
549
574
  const modifiedMs = await stampOf(recordPath(plugin.store, plugin.name))
550
575
  if (modifiedMs === recordMs) return false
551
576
  recordMs = modifiedMs
552
577
  const current = await readStarted(claude())
553
578
  if (!current.isInstalled) return false
554
579
  plugin = current
580
+ return true
581
+ }
582
+ const startInstalled = async () => {
555
583
  failure = undefined
556
584
  activation = undefined
557
585
  await refreshGrants()
558
586
  await finish()
587
+ }
588
+ let settleAsked: (() => void) | undefined
589
+ const recover = async (): Promise<boolean> => {
590
+ if ((settleAsked === undefined && phase !== 'failed' && phase !== 'declined') || !(await isNewlyInstalled())) return false
591
+ if (settleAsked === undefined) await startInstalled()
592
+ else settleAsked()
559
593
  return true
560
594
  }
595
+ let recordCheck: Timer | undefined
596
+ let checking: Promise<void> | undefined
597
+ const watchRecord = () => {
598
+ recordCheck ??= claude().clock.every(recordCheckMs, () => {
599
+ checking ??= recover()
600
+ .then(() => undefined)
601
+ .catch((error: unknown) => claude().ui.log(`${definition.name} could not read whether another cmod installed it: ${messageOf(error)}`, { to: 'debug' }))
602
+ .finally(() => {
603
+ checking = undefined
604
+ })
605
+ })
606
+ }
607
+ const stopWatchingRecord = () => {
608
+ recordCheck?.cancel()
609
+ recordCheck = undefined
610
+ }
561
611
 
562
612
  const refreshGrants = async () => {
563
613
  if (plugin === undefined || plugin.name === cmodPluginName) return
@@ -639,6 +689,7 @@ export function createLifecycle<State extends object, Declared extends Options =
639
689
  runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name, () => plugin?.version, () => openSettings('permissions')) }
640
690
  readStarted = read
641
691
  try {
692
+ drawCount = (await claudeCalls.clock.now()) * drawsPerMs
642
693
  await programs.refresh()
643
694
  await begin().catch(report)
644
695
  } finally {
@@ -647,8 +698,13 @@ export function createLifecycle<State extends object, Declared extends Options =
647
698
  },
648
699
  async route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
649
700
  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)
701
+ const asked = e as Frozen<Args<'cmod.call'>> & { readonly caller?: string }
702
+ if (event === 'cmod.call' && asked.to === definition.name && asked.caller === cmodPluginName) {
703
+ if (asked.method === partMethods.shown) installer.hear(asked.input as ShownPages)
704
+ const answered = installer.answer(asked.method, asked.input)
705
+ if (answered !== undefined) return (await answered.catch((error: unknown) => ({ deny: `${definition.name}: ${messageOf(error)}` }))) as EventResult<N>
706
+ if (asked.method === partMethods.shown && phase !== 'active') return { value: null } as EventResult<N>
707
+ }
652
708
  if (event === 'classic.SessionStart') {
653
709
  missedSessionStart = undefined
654
710
  hasHeardSessionStart = true
@@ -659,9 +715,10 @@ export function createLifecycle<State extends object, Declared extends Options =
659
715
  }
660
716
  if (shouldRecord && event === 'classic.UserPromptSubmit') record()
661
717
  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
718
+ const tool = (e as { readonly tool?: unknown }).tool
719
+ 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
720
  if (event === 'cmod.call' && !isCallToThisMod) return next(e)
664
- await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
721
+ await (event === 'classic.SessionStart' || event === 'session.append' || isCallToThisMod ? holdUntilStarted() : whenActive())
665
722
  if (event === 'classic.SessionStart' && phase !== 'active') {
666
723
  missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
667
724
  return next(e)
@@ -673,7 +730,11 @@ export function createLifecycle<State extends object, Declared extends Options =
673
730
  const call = e as Frozen<Args<'cmod.call'>>
674
731
  if (event === 'cmod.call' && call.to === definition.name && mod !== undefined) {
675
732
  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>
733
+ if (call.method === finishStepsMethod) {
734
+ const active = mod
735
+ 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()]))
736
+ return { value: opened } as EventResult<N>
737
+ }
677
738
  }
678
739
  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
740
  if (event === 'classic.UserPromptSubmit' && phase === 'active' && !hasHeardSessionStart) {
@@ -686,14 +747,15 @@ export function createLifecycle<State extends object, Declared extends Options =
686
747
  if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined) return answer
687
748
  return runtime.toaster.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.props, render.requestId) as EventResult<N>
688
749
  },
689
- async caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, { kind, message }: HookFailure): Promise<EventResult<N>> {
750
+ async caught<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>, { error: { kind, message }, called }: Caught): Promise<EventResult<N>> {
690
751
  const call = e as Frozen<Args<'cmod.call'>>
691
752
  const isAsk = event === 'cmod.call' && call.to === definition.name && beforeAskOf(call) !== undefined
692
753
  const reasons: Readonly<Record<string, string>> = { timeout: `it ran past the ${hookMs / 1000} s Claude Code gives a hook`, throw: message ?? 'it threw' }
693
754
  const reason = reasons[kind]
694
755
  if (reason === undefined) return isAsk ? ({ value: null } as EventResult<N>) : next(e)
695
756
  const error = new Error(reason)
696
- toastFailure({ id: `stopped:${event}`, message: `Claude Code stopped its ${event.replace(/^classic\./, '')} hook`, error })
757
+ const failedBeneath = called && kind === 'throw' ? await next(e).then(() => undefined, messageOf) : undefined
758
+ if (failedBeneath === undefined || failedBeneath !== message) toastFailure({ id: `stopped:${event}`, message: `Claude Code stopped its ${event.replace(/^classic\./, '')} hook`, error })
697
759
  return router.dispatchFailure(event, e, error, next)
698
760
  },
699
761
  }
@@ -737,7 +799,13 @@ async function createMod<State extends object, Declared extends Options>(definit
737
799
  let parentAsked: Promise<string | undefined> | undefined
738
800
  let hasEarlierPrompts = true
739
801
  let agentType: string | undefined
740
- const area = createUi<State>({ name: definition.name, claude, router, report, announce, mod: () => mod })
802
+ let parts: DrawnParts | undefined
803
+ const borrowed: BorrowedMarkdown = (styler, cached, input) => {
804
+ const name = `The markdown style of ${styler}`
805
+ void parts?.fill(styler, cached, input, name)?.then(() => claude.ui.invalidate('ui.render'))
806
+ return parts?.element(styler, cached, `${styler}/markdown:`, name, `markdown:${styler}`)
807
+ }
808
+ const area = createUi<State>({ name: definition.name, claude, router, report, announce, mod: () => mod, borrowed, nextDraw: runtime.nextDraw })
741
809
  const askParent = async (): Promise<string | undefined> => {
742
810
  if (definition.name === cmodPluginName) return undefined
743
811
  try {
@@ -1003,9 +1071,23 @@ async function createMod<State extends object, Declared extends Options>(definit
1003
1071
  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
1072
  let pages: Pages | undefined
1005
1073
  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 })
1074
+ const pressElsewhere = (to: string, input: PartPress) =>
1075
+ void claude.cmod.call({ to, method: partMethods.press, input }).catch((error: unknown) => {
1076
+ parts?.forget(to)
1077
+ claude.ui.invalidate('ui.render')
1078
+ reportPart(`press:${to}`, `${to} did not take the press`, error)
1079
+ })
1080
+ parts = drawnParts({ claude, report: reportPart, press: (to, input) => (pages === undefined ? pressElsewhere(to, input) : pages.press(to, input)) })
1081
+ const widgets = conversations === undefined ? undefined : serveWidgets({ claude, router, store: runtime.store, report: reportPart, turnClosedBy: (rowId) => conversations.turnClosedBy(rowId), parts, markdownIn: area.markdownIn })
1082
+ pages = widgets === undefined ? undefined : servePages({ claude, router, report: reportPart, widgets, parts, tellStylers: area.restyled })
1083
+ const tellColor = (to: string, told: SessionColor) => {
1084
+ if (told.session === '' || to === cmodPluginName) return
1085
+ claude.clock.after(0, () => void claude.cmod.call({ to, method: conversationMethods.sessionColor, input: told }).catch((error: unknown) => claude.ui.log(`cmod: ${to} did not hear the session's color: ${messageOf(error)}`, { to: 'debug' })))
1086
+ }
1087
+ conversations?.onColor((told) => {
1088
+ client.recolor(told)
1089
+ for (const { mod: to } of pages?.declared() ?? []) tellColor(to, told)
1090
+ })
1009
1091
  router.add('cmod.call', async (e, next) => {
1010
1092
  if (e.to !== definition.name) return next(e)
1011
1093
  const call = e as typeof e & { readonly caller?: string }
@@ -1015,15 +1097,21 @@ async function createMod<State extends object, Declared extends Options>(definit
1015
1097
  client.changed(changedSession, change, isInProject !== false)
1016
1098
  return { value: null }
1017
1099
  }
1100
+ if (e.method === conversationMethods.sessionColor && call.caller === cmodPluginName) {
1101
+ client.recolor(e.input as SessionColor)
1102
+ return { value: null }
1103
+ }
1018
1104
  const ask = beforeAskOf(call)
1019
1105
  if (ask !== undefined) return { value: (await client.answerBefore(ask.session, ask.event, ask.change, ask.isInProject)) ?? null }
1020
1106
  if (e.method === partMethods.changed && call.caller !== undefined && pages !== undefined) {
1021
- return { value: { isHeld: pages.update(call.caller, e.input as PartsUpdate) } }
1107
+ const update = e.input as PartsUpdate
1108
+ if (update.parts !== undefined && conversations !== undefined) tellColor(call.caller, conversations.color())
1109
+ return { value: { isHeld: pages.update(call.caller, update), stylers: pages.stylers() } }
1022
1110
  }
1023
- if (call.caller === cmodPluginName) {
1111
+ if (call.caller !== undefined) {
1024
1112
  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
1113
+ const answered = area.answer(e.method, turn === undefined ? e.input : { ...(e.input as PartDraw), turn: client.turn(turn) }, call.caller)
1114
+ if (answered !== undefined) return answered.catch((error: unknown) => ({ deny: `${definition.name}: ${messageOf(error)}` }))
1027
1115
  }
1028
1116
  const served = await conversations?.answer(call)
1029
1117
  return served ?? answerCall(definition.name, api, e)
@@ -1033,7 +1121,20 @@ async function createMod<State extends object, Declared extends Options>(definit
1033
1121
  return ask === undefined ? { deny: `${definition.name}: ${error.message}` } : { value: client.failedBefore(ask.session, ask.event, ask.change, ask.isInProject, error) ?? null }
1034
1122
  })
1035
1123
  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 }) })
1124
+ keepEngine(mod, {
1125
+ claude: checked,
1126
+ router,
1127
+ toolCalls: agents,
1128
+ report,
1129
+ hasPermissionCheck: () => runtime.permissionRoutes() !== undefined,
1130
+ answerInput: area.answerInput,
1131
+ spawnCmod: async (args, program) => {
1132
+ await checkRun(grants, 'process.spawn', program.argv)
1133
+ return spawnCmod(claude, args, session, program.env)
1134
+ },
1135
+ ...(widgets === undefined ? {} : { widgets }),
1136
+ ...(pages === undefined ? {} : { pages }),
1137
+ })
1037
1138
  const names = new Set<string>()
1038
1139
  const reserveName = (type: string, name: string, taken: string): Reservation => {
1039
1140
  const key = `${type}:${name}`
@@ -1,10 +1,12 @@
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
- import { partMethods, type PageDeclaration, type PageRequest, type PartPress, type PartsUpdate, type ShownPages } from './ui.js'
9
+ import { partMethods, stalePress, type PageDeclaration, type PageRequest, type PartPress, type PartsUpdate, type ShownPages } from './ui.js'
8
10
  import type { Widgets } from './widgets.js'
9
11
 
10
12
  const askedWaitMs = 1000
@@ -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 })
@@ -96,7 +129,8 @@ export function servePages({ claude, router, report, widgets, parts }: { readonl
96
129
  (error: unknown) => {
97
130
  parts.forget(mod)
98
131
  claude.ui.invalidate('ui.render')
99
- report(`press:${mod}`, `${mod} did not take the press`, error)
132
+ if (messageOf(error).includes(stalePress)) claude.ui.log(`cmod: ${mod} draws its page again, because the button pressed was from an older drawing:${messageOf(error)}`, { to: 'debug' })
133
+ else report(`press:${mod}`, `${mod} did not take the press`, error)
100
134
  },
101
135
  )
102
136
  .finally(() => pressing.delete(pending))
@@ -104,21 +138,31 @@ export function servePages({ claude, router, report, widgets, parts }: { readonl
104
138
  if (inputDepth === 0) void pending.then(takeWaiting)
105
139
  }
106
140
 
107
- const cacheKey = (page: string, params: unknown, columns: number, isFocused: boolean) => `${page}\u0000${JSON.stringify(params ?? null)}\u0000${columns}\u0000${isFocused}`
141
+ const lastDrawn = new Map<string, { readonly params: string; readonly cached: string }>()
142
+
143
+ const plain = <Data,>(data: Data): Data => JSON.parse(JSON.stringify(data)) as Data
108
144
 
109
145
  return {
110
146
  declared: () => [...declared].map(([mod, pages]) => ({ mod, pages })),
147
+ stylers: () => [...stylers],
111
148
  element(mod, page, params, columns, isFocused) {
112
149
  if (!declared.has(mod)) void ask(mod)
113
- const cached = cacheKey(page, params, columns, isFocused)
150
+ const given = JSON.stringify(params ?? null)
151
+ const cached = `${page}\u0000${given}\u0000${columns}\u0000${isFocused}`
114
152
  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}`)
153
+ const keyPrefix = `${mod}/${page}:`
154
+ void parts.fill(mod, cached, { page, params: plain(params ?? null), columns, isFocused }, name)?.then(() => claude.ui.invalidate('ui.render'))
155
+ const failure = parts.failure(mod, cached)
156
+ const drawnAt = (key: string) => parts.element(mod, key, keyPrefix, name, `page:${mod}/${page}`)
157
+ const last = lastDrawn.get(keyPrefix)
158
+ const drawn = drawnAt(cached)
159
+ if (drawn !== undefined) lastDrawn.set(keyPrefix, { params: given, cached })
160
+ return drawn ?? (last?.params === given ? drawnAt(last.cached) : undefined) ?? (failure === undefined ? undefined : Text({ color: 'error', children: `${name} could not draw: ${failure}` }))
117
161
  },
118
162
  press,
119
163
  update,
120
164
  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' }))
165
+ 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
166
  },
123
167
  apply(listener) {
124
168
  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
  }