@cmodjs/core 3.1.0 → 4.0.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 (200) hide show
  1. package/docs/commands.md +38 -21
  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 +93 -65
  7. package/docs/options.md +2 -2
  8. package/docs/permissions.md +4 -4
  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 +13 -8
  45. package/records.d.ts.map +1 -1
  46. package/records.js +41 -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 +59 -0
  82. package/runtime/permission-check.d.ts +2 -2
  83. package/runtime/permission-check.d.ts.map +1 -1
  84. package/runtime/programs.js +1 -1
  85. package/runtime/router.d.ts +2 -2
  86. package/runtime/router.d.ts.map +1 -1
  87. package/runtime/router.js +1 -1
  88. package/runtime/sessions.d.ts +10 -9
  89. package/runtime/sessions.d.ts.map +1 -1
  90. package/runtime/sessions.js +14 -5
  91. package/runtime/state.d.ts +2 -1
  92. package/runtime/state.d.ts.map +1 -1
  93. package/runtime/state.js +1 -0
  94. package/runtime/toaster.d.ts +1 -1
  95. package/runtime/toaster.d.ts.map +1 -1
  96. package/runtime/toaster.js +10 -4
  97. package/runtime/ui.d.ts +87 -5
  98. package/runtime/ui.d.ts.map +1 -1
  99. package/runtime/ui.js +374 -109
  100. package/runtime/watches.d.ts +9 -0
  101. package/runtime/watches.d.ts.map +1 -0
  102. package/runtime/watches.js +73 -0
  103. package/runtime/widgets.d.ts +48 -0
  104. package/runtime/widgets.d.ts.map +1 -0
  105. package/runtime/widgets.js +219 -0
  106. package/session/context/turn/message/message.d.ts.map +1 -1
  107. package/session/context/turn/message/message.js +7 -2
  108. package/session/session.d.ts +11 -5
  109. package/session/session.d.ts.map +1 -1
  110. package/src/hotkeys.ts +51 -0
  111. package/src/installer.ts +8 -4
  112. package/src/jobs/check.ts +4 -3
  113. package/src/jobs/context.ts +3 -2
  114. package/src/jobs/permissions.ts +4 -3
  115. package/src/jobs/program.ts +1 -1
  116. package/src/jobs/prompt.ts +23 -14
  117. package/src/jobs/schedule.ts +16 -2
  118. package/src/jobs/slash-command.ts +10 -7
  119. package/src/jobs/tool.ts +7 -5
  120. package/src/mod.ts +59 -47
  121. package/src/records.ts +46 -31
  122. package/src/register.ts +2 -3
  123. package/src/runtime/announce.ts +6 -7
  124. package/src/runtime/before.ts +4 -4
  125. package/src/runtime/claude.ts +0 -1
  126. package/src/runtime/conversations.ts +66 -20
  127. package/src/runtime/dependencies.ts +2 -1
  128. package/src/runtime/engine.ts +5 -0
  129. package/src/runtime/grants.ts +2 -2
  130. package/src/runtime/hooks.ts +1 -0
  131. package/src/runtime/hotkeys.ts +80 -0
  132. package/src/runtime/installer.ts +24 -12
  133. package/src/runtime/lifecycle.ts +189 -136
  134. package/src/runtime/options.ts +2 -2
  135. package/src/runtime/pages.ts +127 -0
  136. package/src/runtime/parts.ts +70 -0
  137. package/src/runtime/permission-check.ts +2 -2
  138. package/src/runtime/programs.ts +1 -1
  139. package/src/runtime/router.ts +3 -3
  140. package/src/runtime/sessions.ts +24 -15
  141. package/src/runtime/state.ts +3 -1
  142. package/src/runtime/toaster.ts +10 -4
  143. package/src/runtime/ui.ts +407 -115
  144. package/src/runtime/watches.ts +75 -0
  145. package/src/runtime/widgets.ts +287 -0
  146. package/src/session/context/turn/message/message.ts +7 -2
  147. package/src/session/session.ts +10 -6
  148. package/src/testing/fake-claude.ts +9 -6
  149. package/src/testing/fake-elements.ts +7 -4
  150. package/src/testing/fake-sessions.ts +6 -4
  151. package/src/testing.ts +203 -46
  152. package/src/ui/areas.ts +27 -0
  153. package/src/ui/elements.ts +35 -0
  154. package/src/ui/markdown.ts +18 -18
  155. package/src/ui/panes.ts +73 -0
  156. package/src/ui/styles.ts +53 -0
  157. package/src/utils/subscription.ts +1 -0
  158. package/testing/fake-claude.d.ts +0 -1
  159. package/testing/fake-claude.d.ts.map +1 -1
  160. package/testing/fake-claude.js +9 -5
  161. package/testing/fake-elements.js +7 -4
  162. package/testing/fake-sessions.d.ts +2 -1
  163. package/testing/fake-sessions.d.ts.map +1 -1
  164. package/testing/fake-sessions.js +1 -0
  165. package/testing.d.ts +21 -6
  166. package/testing.d.ts.map +1 -1
  167. package/testing.js +206 -45
  168. package/ui/areas.d.ts +29 -0
  169. package/ui/areas.d.ts.map +1 -0
  170. package/ui/areas.js +6 -0
  171. package/ui/elements.d.ts +3 -0
  172. package/ui/elements.d.ts.map +1 -1
  173. package/ui/elements.js +36 -0
  174. package/ui/markdown.d.ts +14 -14
  175. package/ui/markdown.d.ts.map +1 -1
  176. package/ui/markdown.js +11 -11
  177. package/ui/panes.d.ts +71 -0
  178. package/ui/panes.d.ts.map +1 -0
  179. package/ui/panes.js +16 -0
  180. package/ui/styles.d.ts +44 -0
  181. package/ui/styles.d.ts.map +1 -0
  182. package/ui/styles.js +24 -0
  183. package/utils/subscription.d.ts +4 -0
  184. package/utils/subscription.d.ts.map +1 -0
  185. package/jobs/status-line.d.ts +0 -6
  186. package/jobs/status-line.d.ts.map +0 -1
  187. package/jobs/status-line.js +0 -39
  188. package/src/jobs/status-line.ts +0 -44
  189. package/src/ui/define-pane.ts +0 -27
  190. package/src/ui/places.ts +0 -49
  191. package/src/utils/disposable.ts +0 -3
  192. package/ui/define-pane.d.ts +0 -15
  193. package/ui/define-pane.d.ts.map +0 -1
  194. package/ui/define-pane.js +0 -12
  195. package/ui/places.d.ts +0 -43
  196. package/ui/places.d.ts.map +0 -1
  197. package/ui/places.js +0 -31
  198. package/utils/disposable.d.ts +0 -4
  199. package/utils/disposable.d.ts.map +0 -1
  200. /package/utils/{disposable.js → subscription.js} +0 -0
@@ -1,9 +1,8 @@
1
- import { changelogPage, isObject, marketplaceOf, permissionWords } from '../records.js'
1
+ import { isObject, marketplaceOf, permissionWords } from '../records.js'
2
2
  import { changeLines, changelogSections, unreleased } from '../utils/changelog.js'
3
3
  import { ifMissing } from '../utils/paths.js'
4
4
  import { listed } from '../utils/text.js'
5
5
  import type { Claude } from './claude.js'
6
- import { cmodPluginName } from './dependencies.js'
7
6
  import type { Plugin } from './lifecycle.js'
8
7
 
9
8
  export type Announced = { readonly version: string; readonly added: readonly string[] }
@@ -20,20 +19,20 @@ export function announcedOf(saved: unknown): Announced | undefined {
20
19
  return { version: saved['version'], added: Array.isArray(saved['added']) ? saved['added'].map(String) : [] }
21
20
  }
22
21
 
23
- export async function announcementOf(claude: Claude, plugin: Plugin, granted: ReadonlySet<string>, added: readonly string[], previous: Announced | undefined): Promise<Announcement> {
22
+ export async function announcementOf(claude: Claude, plugin: Plugin, granted: ReadonlySet<string>, added: readonly string[], previous: Announced | undefined, openSettings: (tab?: 'changelog') => Promise<void>): Promise<Announcement> {
24
23
  const read = (path: string) => claude.fs.read(`${plugin.root}/${path}`).catch(ifMissing(undefined))
25
24
  const manifest = jsonOf(await read('.claude-plugin/plugin.json'))
26
25
  const field = (key: string) => (isObject(manifest) && typeof manifest[key] === 'string' ? manifest[key] : undefined)
27
- const open = (page?: string) => () => claude.cmod.call({ to: cmodPluginName, method: 'openSettings', input: { mod: plugin.name, ...(page === undefined ? {} : { page }) } })
26
+ const open = (tab?: 'changelog') => () => openSettings(tab)
28
27
  if (previous === undefined) {
29
- const { program, keys } = plugin.steps
28
+ const { program, hotkeys } = plugin.steps
30
29
  const rules = (await claude.fs.list(`${plugin.root}/rules`).catch(ifMissing([]))).filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md')).map((entry) => entry.name.slice(0, -'.md'.length)).toSorted()
31
30
  const lines = [
32
31
  field('description'),
33
32
  added.length > 0 ? `Adds ${listed(added)}.` : undefined,
34
33
  rules.length > 0 ? `Adds ${rules.length} ${rules.length === 1 ? 'rule' : 'rules'}: ${listed(rules)}.` : undefined,
35
34
  program === undefined ? undefined : `Installed the ${program} program.`,
36
- ...Object.entries(keys ?? {}).map(([key, command]) => `Binds ${key} to /${command}.`),
35
+ ...Object.values(hotkeys ?? {}).map(({ title, default: combo }) => `Binds ${combo} to ${title}.`),
37
36
  ...[...granted].map((item) => `${permissionWords(item)}.`),
38
37
  ]
39
38
  return { message: 'Installed', ...described(lines), action: { label: 'Settings', onClick: open() } }
@@ -57,7 +56,7 @@ export async function announcementOf(claude: Claude, plugin: Plugin, granted: Re
57
56
  return {
58
57
  message: `Updated to ${version}`,
59
58
  ...described(lines),
60
- ...(sections.length > 0 ? { action: { label: 'Changelog', onClick: open(changelogPage) } } : {}),
59
+ ...(sections.length > 0 ? { action: { label: 'Changelog', onClick: open('changelog') } } : {}),
61
60
  }
62
61
  }
63
62
 
@@ -1,5 +1,5 @@
1
1
  import type { Args, Frozen } from 'claude-code'
2
- import type { Disposable } from '../mod.js'
2
+ import type { Subscription } from '../utils/subscription.js'
3
3
  import { ContextEnd } from '../session/context/context.js'
4
4
  import { InstructionFile, isRulePath } from '../session/context/turn/message/instruction-file.js'
5
5
  import { instructionPathOf, Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
@@ -55,7 +55,7 @@ export function relayOf(e: Frozen<Args<'session.receive'>>): string | undefined
55
55
  return isFromCmod ? relayTag.exec(e.text)?.[1] : undefined
56
56
  }
57
57
 
58
- export function installBefore({ claude, router, hooks }: { readonly claude: Claude; readonly router: Router; readonly hooks: BeforeHooks }): Disposable {
58
+ export function installBefore({ claude, router, hooks }: { readonly claude: Claude; readonly router: Router; readonly hooks: BeforeHooks }): Subscription {
59
59
  const { place, wants, fold } = hooks
60
60
  const now = () => new Date().toISOString()
61
61
  const asked = async (event: BeforeEvent, change: SessionChange, decide: Decide = fold()) => (await decide(event, change)) ?? {}
@@ -82,7 +82,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
82
82
  const failed = answer.isError === true && !isFailure ? { block: answer.note === undefined ? `${name} failed.` : untagged(answer.note) } : {}
83
83
  return { ...withNote(below, answer.isError === true ? undefined : answer.note), ...rewritten, ...failed }
84
84
  }
85
- const added: Disposable[] = [
85
+ const added: Subscription[] = [
86
86
  router.add('prompt.submit', async (e, next) => {
87
87
  if (!(await wants(Prompt))) return next(e)
88
88
  const where = await place()
@@ -215,7 +215,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
215
215
  return answer.refuse === undefined ? next(e) : { skip: answer.refuse }
216
216
  }),
217
217
  ]
218
- return { dispose: () => added.forEach((each) => each.dispose()) }
218
+ return { remove: () => added.forEach((each) => each.remove()) }
219
219
  }
220
220
 
221
221
  export function closingContext(where: Place, task: RunningTask | undefined, closedAt: string): ContextData {
@@ -46,7 +46,6 @@ import type {
46
46
  export type Claude = {
47
47
  readonly plugin: { readonly name: string; readonly root: string }
48
48
  readonly ui: {
49
- status(text: string | undefined): void
50
49
  log(text: string, options?: UiLogOptions): void
51
50
  notice(toolUseId: string, text: string | undefined): void
52
51
  invalidate(event: InvalidatableEventName): void
@@ -1,11 +1,12 @@
1
1
  import type { Args, EventResult, Frozen } from 'claude-code'
2
- import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
2
+ import { consentPath, coreVersion, dataFolder, grantFix, isObject, parseConsent } from '../records.js'
3
3
  import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
4
4
  import { modNote, Note } from '../session/context/turn/message/note.js'
5
5
  import { Person } from '../session/context/turn/message/sender.js'
6
6
  import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
7
7
  import { Session, UsageChange, type BeforeEvent, type Surface, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
8
8
  import { GoalChange } from '../session/goal/goal.js'
9
+ import { TurnEnd, TurnStart } from '../session/context/turn/turn.js'
9
10
  import { receivedMatcher } from '../utils/matcher.js'
10
11
  import { ifMissing } from '../utils/paths.js'
11
12
  import { formatExit, messageOf } from '../utils/text.js'
@@ -15,13 +16,13 @@ import { cmodPluginName } from './dependencies.js'
15
16
  import { readLines, runCmod } from './programs.js'
16
17
  import type { RoutedHook, Router } from './router.js'
17
18
  import { PassedTimeout } from './deadline.js'
18
- import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
19
+ import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, resultOf, spawnTask, usageOf, type Act, type Listened } from './sessions.js'
19
20
 
20
21
  type Request = Readonly<Record<string, unknown>>
21
22
 
22
- type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
23
+ type Heard = { readonly event: Listened['event']; readonly matcher: unknown }
23
24
 
24
- type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[]; readonly refusing?: readonly Subscription[] }
25
+ type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[]; readonly refusing?: readonly Listened[] }
25
26
 
26
27
  type Watcher = Watch & { readonly from: string }
27
28
 
@@ -31,6 +32,10 @@ type Question =
31
32
 
32
33
  type Relay = (Question & { readonly id: string; readonly from: string }) | { readonly kind: 'answer'; readonly id: string; readonly value?: unknown; readonly deny?: string } | { readonly kind: 'watch' } | { readonly kind: 'goal'; readonly task?: string; readonly text: string }
33
34
 
35
+ const relayKinds: ReadonlySet<unknown> = new Set(['act', 'before', 'answer', 'watch', 'goal'] satisfies Relay['kind'][])
36
+
37
+ const isRelay = (value: unknown): value is Relay => isObject(value) && relayKinds.has(value['kind'])
38
+
34
39
  type Asked = { resolve(value: unknown): void; reject(error: Error): void }
35
40
 
36
41
 
@@ -46,9 +51,28 @@ const livenessMs = 5000
46
51
  export type Conversations = {
47
52
  answer(e: Frozen<Args<'cmod.call'>> & { readonly caller?: string }): Promise<EventResult<'cmod.call'> | undefined>
48
53
  moved(): void
54
+ turnClosedBy(rowId: string): TurnData | undefined
49
55
  }
50
56
 
51
- export function serveConversations({ claude, router, store, project, report }: { readonly claude: Claude; readonly router: Router; readonly store: string; readonly project: () => string; readonly report: (id: string, message: string, error: unknown) => void }): Conversations {
57
+ const keptClosings = 200
58
+
59
+ const keptMessages = 200
60
+
61
+ export function serveConversations({
62
+ claude,
63
+ router,
64
+ store,
65
+ project,
66
+ report,
67
+ clear,
68
+ }: {
69
+ readonly claude: Claude
70
+ readonly router: Router
71
+ readonly store: string
72
+ readonly project: () => string
73
+ readonly report: (id: string, message: string, error: unknown) => void
74
+ readonly clear: (id: string) => void
75
+ }): Conversations {
52
76
  const folder = dataFolder(store, cmodPluginName)
53
77
  const goals = `${folder}/goals`
54
78
  const watches = `${folder}/before`
@@ -98,16 +122,23 @@ export function serveConversations({ claude, router, store, project, report }: {
98
122
 
99
123
  const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
100
124
 
125
+ let lastTurn: TurnData | undefined
126
+ const closings = new Map<string, TurnData>()
101
127
  const dispatch = (to: string, changes: readonly SessionChange[], isInProject = true) => {
102
128
  for (const change of changes) {
129
+ if (to === session && (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.task === undefined) lastTurn = change.turn
103
130
  if (change.type === Message && !liveIds.has(change.message.id) && to === session) {
104
131
  live.push(change.message)
105
132
  liveIds.add(change.message.id)
133
+ if (live.length > keptMessages) liveIds.delete(live.shift()?.id ?? '')
106
134
  }
107
135
  for (const [mod, sessions] of heard) {
108
136
  const subscriptions = [...(sessions.get(to) ?? []), ...(isInProject ? (sessions.get(everySession) ?? []) : [])]
109
137
  if (!subscriptions.some(({ event, matcher }) => isHeardBy(event, matcher, change))) continue
110
- void claude.cmod.call({ to: mod, method: conversationMethods.changed, input: { session: to, change, isInProject } }).catch((error: unknown) => report(`changed:${mod}`, `Could not tell ${mod} about session ${to}`, error))
138
+ void claude.cmod.call({ to: mod, method: conversationMethods.changed, input: { session: to, change, isInProject } }).then(
139
+ () => clear(`changed:${mod}`),
140
+ (error: unknown) => report(`changed:${mod}`, `Could not tell ${mod} about session ${to}`, error),
141
+ )
111
142
  }
112
143
  }
113
144
  }
@@ -239,7 +270,7 @@ export function serveConversations({ claude, router, store, project, report }: {
239
270
  }
240
271
 
241
272
  const askHere = async (mod: string, event: BeforeEvent, change: SessionChange, from = session, isInProject = true) => {
242
- if (from !== session && !(await isGranted(mod, 'conversation'))) throw new Error(`${mod} needs the conversation permission to change what happens in another session. Turn it on in /mods ${mod}.`)
273
+ if (from !== session && !(await isGranted(mod, 'conversation'))) throw new Error(`${mod} needs the conversation permission to change what happens in another session. ${grantFix}`)
243
274
  return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
244
275
  }
245
276
 
@@ -333,6 +364,9 @@ export function serveConversations({ claude, router, store, project, report }: {
333
364
  return
334
365
  }
335
366
  dispatch(session, conversation.add(row, task))
367
+ if (task !== undefined || row.type !== 'system' || row.name !== 'turn_duration' || lastTurn === undefined) return
368
+ closings.set(row.id, lastTurn)
369
+ if (closings.size > keptClosings) closings.delete(closings.keys().next().value as string)
336
370
  }
337
371
 
338
372
  router.add('classic.SessionStart', async (e, next) => {
@@ -386,11 +420,18 @@ export function serveConversations({ claude, router, store, project, report }: {
386
420
  router.add('session.receive', async (e, next) => {
387
421
  const sent = relayOf(e)
388
422
  if (sent === undefined) return next(e)
389
- const relay = JSON.parse(sent) as Relay
390
- claude.clock.after(0, () => void received(relay).catch((error: unknown) => report('relay', 'Could not answer another session', error)))
423
+ const relay = JSON.parse(sent) as unknown
424
+ claude.clock.after(0, () => void (isRelay(relay) ? received(relay) : refuse(relay)).catch((error: unknown) => report('relay', 'Could not answer another session', error)))
391
425
  return { consumed: 'cmod relay' }
392
426
  })
393
427
 
428
+ const unknownTo = async (what: string) => new Error(`Session ${await claude.session.id()} runs cmod ${coreVersion}, which has no ${what}.`)
429
+
430
+ const refuse = async (relay: unknown) => {
431
+ if (!isObject(relay) || typeof relay['id'] !== 'string' || typeof relay['from'] !== 'string') return
432
+ await post(relay['from'], { kind: 'answer', id: relay['id'], deny: (await unknownTo(`"${String(relay['kind'])}" relay`)).message })
433
+ }
434
+
394
435
  const received = async (relay: Relay) => {
395
436
  if (relay.kind === 'answer') {
396
437
  const waiting = asked.get(relay.id)
@@ -411,13 +452,13 @@ export function serveConversations({ claude, router, store, project, report }: {
411
452
  await post(relay.from, { kind: 'answer', id: relay.id, deny: messageOf(error) })
412
453
  }
413
454
  }
414
- if (relay.kind === 'act') return answered(() => perform(relay.caller, relay.action, relay.input, relay.task))
455
+ if (relay.kind === 'act') return answered(async () => (isAct(relay.action) ? perform(relay.caller, relay.action, relay.input, relay.task) : Promise.reject(await unknownTo(`"${String(relay.action)}" action`))))
415
456
  return answered(() => askHere(relay.mod, relay.event, relay.change, relay.from, relay.project === project()))
416
457
  }
417
458
 
418
459
  const checkAct = async (caller: string, action: Act, input: unknown) => {
419
460
  const needed = actPermission(action, input)
420
- if (needed !== undefined && !(await isGranted(caller, needed))) throw new Error(`${caller} needs the ${needed} permission for ${action}. Turn it on in /mods ${caller}.`)
461
+ if (needed !== undefined && !(await isGranted(caller, needed))) throw new Error(`${caller} needs the ${needed} permission for ${action}. ${grantFix}`)
421
462
  }
422
463
 
423
464
  const perform = async (caller: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
@@ -509,6 +550,8 @@ export function serveConversations({ claude, router, store, project, report }: {
509
550
 
510
551
  const lastOf = <Item>(items: readonly Item[], last: unknown): Item[] => (typeof last === 'number' ? items.slice(-last) : [...items])
511
552
 
553
+ const isHeld = (kept: readonly unknown[], last: unknown) => typeof last === 'number' && kept.length >= last
554
+
512
555
  const methods: Readonly<Record<string, (request: Request, caller: string | undefined) => Promise<unknown>>> = {
513
556
  async [conversationMethods.sessions](request) {
514
557
  const found = await json<SessionData[]>(['history', 'sessions', ...scopeArgs(request), ...(request['running'] === true ? ['--running'] : [])])
@@ -522,21 +565,23 @@ export function serveConversations({ claude, router, store, project, report }: {
522
565
  async [conversationMethods.messages](request) {
523
566
  const id = String(request['session'])
524
567
  const filters = [...textArg('--turn', request['turn']), ...textArg('--task', request['task']), ...textArg('--after', request['after'])]
525
- const saved = await json<Message[]>(['history', 'messages', id, ...filters, ...countArg('--last', request['last'])])
526
- if (id !== session) return saved
568
+ const saved = () => json<Message[]>(['history', 'messages', id, ...filters, ...countArg('--last', request['last'])])
569
+ if (id !== session) return saved()
527
570
  await starting
528
571
  const fits = (message: Message) => (request['turn'] === undefined || message.turn === request['turn']) && message.task === request['task']
529
572
  const after = typeof request['after'] === 'string' ? live.findIndex((message) => message.id === request['after']) : -1
530
573
  const kept = live.slice(after + 1).filter(fits)
531
- return lastOf(merged(saved, kept, (message) => message.id), request['last'])
574
+ if (after >= 0 || isHeld(kept, request['last'])) return lastOf(kept, request['last'])
575
+ return lastOf(merged(await saved(), kept, (message) => message.id), request['last'])
532
576
  },
533
577
  async [conversationMethods.turns](request) {
534
578
  const id = String(request['session'])
535
- const saved = await json<TurnData[]>(['history', 'turns', id, ...textArg('--task', request['task']), ...countArg('--context', request['context']), ...countArg('--last', request['last'])])
536
- if (id !== session) return saved
579
+ const saved = () => json<TurnData[]>(['history', 'turns', id, ...textArg('--task', request['task']), ...countArg('--context', request['context']), ...countArg('--last', request['last'])])
580
+ if (id !== session) return saved()
537
581
  await starting
538
582
  const kept = liveTurns(request['task']).filter((turn) => request['context'] === undefined || turn.context === request['context'])
539
- return lastOf(merged(saved, kept, (turn) => turn.prompt.id), request['last'])
583
+ if (isHeld(kept, request['last'])) return lastOf(kept, request['last'])
584
+ return lastOf(merged(await saved(), kept, (turn) => turn.prompt.id), request['last'])
540
585
  },
541
586
  async [conversationMethods.contexts](request) {
542
587
  const id = String(request['session'])
@@ -583,7 +628,7 @@ export function serveConversations({ claude, router, store, project, report }: {
583
628
  async [conversationMethods.subscribe](request, caller) {
584
629
  if (caller === undefined) throw new Error('cmod hears no caller for this subscription.')
585
630
  const id = String(request['session'])
586
- const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
631
+ const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Listened[]) : []
587
632
  const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
588
633
  sessions.set(id, list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
589
634
  heard.set(caller, sessions)
@@ -593,7 +638,7 @@ export function serveConversations({ claude, router, store, project, report }: {
593
638
  async [conversationMethods.watchBefore](request, caller) {
594
639
  if (caller === undefined) throw new Error('cmod hears no caller for this before listener.')
595
640
  const target = String(request['session'])
596
- const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
641
+ const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Listened[]) : []
597
642
  ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))], refusing: list.filter((each) => each.onFailure === 'refuse') })
598
643
  await publishWatches()
599
644
  return null
@@ -610,6 +655,7 @@ export function serveConversations({ claude, router, store, project, report }: {
610
655
  }
611
656
 
612
657
  return {
658
+ turnClosedBy: (rowId) => closings.get(rowId),
613
659
  moved() {
614
660
  for (const [key, watch] of ownWatches) ownWatches.set(key, { ...watch, project: project() })
615
661
  void publishWatches().catch((error: unknown) => report('before', 'Kept its before listeners on the project from before', error))
@@ -621,7 +667,7 @@ export function serveConversations({ claude, router, store, project, report }: {
621
667
  if (method === undefined) return undefined
622
668
  const input = isObject(e.input) ? e.input : {}
623
669
  const isDropping = (e.method === conversationMethods.subscribe || e.method === conversationMethods.watchBefore) && Array.isArray(input['subscriptions']) && input['subscriptions'].length === 0
624
- if (e.method !== conversationMethods.act && !isDropping && input['session'] !== (await claude.session.id()) && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
670
+ if (e.method !== conversationMethods.act && !isDropping && input['session'] !== (await claude.session.id()) && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. ${grantFix}` }
625
671
  try {
626
672
  return { value: await method(input, e.caller) }
627
673
  } catch (error) {
@@ -1,4 +1,5 @@
1
1
  import type { Args, CmodDependencies, EventResult, Frozen } from 'claude-code'
2
+ import { installFix } from '../records.js'
2
3
  import { messageOf } from '../utils/text.js'
3
4
  import type { Claude } from './claude.js'
4
5
  import type { Router } from './router.js'
@@ -46,5 +47,5 @@ export function answeringOwnCalls(claude: Claude, router: Router): Claude {
46
47
  }
47
48
 
48
49
  export function notInstalled(name: string): string {
49
- return `${name} is not installed. Add it with /plugin.`
50
+ return `${name} is not installed. ${installFix}`
50
51
  }
@@ -1,7 +1,9 @@
1
1
  import type { Claude } from './claude.js'
2
+ import type { Pages } from './pages.js'
2
3
  import type { Router } from './router.js'
3
4
  import type { Failure } from './toaster.js'
4
5
  import type { ToolCalls } from './tool-calls.js'
6
+ import type { Widgets } from './widgets.js'
5
7
 
6
8
  export type Engine = {
7
9
  readonly claude: Claude
@@ -9,6 +11,9 @@ export type Engine = {
9
11
  readonly toolCalls: ToolCalls
10
12
  readonly report: (failure: Failure) => void
11
13
  readonly hasPermissionCheck: () => boolean
14
+ readonly answerInput: <Answer>(run: () => Promise<Answer>, passOn: () => Promise<unknown>) => Promise<Answer>
15
+ readonly widgets?: Widgets
16
+ readonly pages?: Pages
12
17
  }
13
18
 
14
19
  const engines = new WeakMap<object, Engine>()
@@ -1,4 +1,4 @@
1
- import { isObject, isSettingCovered, permissionWords, settingPaths } from '../records.js'
1
+ import { grantFix, isObject, isSettingCovered, permissionWords, settingPaths } from '../records.js'
2
2
  import { relativePath } from '../utils/paths.js'
3
3
  import type { Claude } from './claude.js'
4
4
  import { cmodPluginName } from './dependencies.js'
@@ -198,7 +198,7 @@ function writeItem(grants: Grants, path: string): string | undefined {
198
198
 
199
199
  export function refusal(grants: Pick<Grants, 'name' | 'declared' | 'home'>, call: string, item: string): string {
200
200
  const { name } = grants
201
- if (isCovered(new Set(grants.declared), item, grants.home)) return `${name} calls ${call} without your grant to "${permissionWords(item)}". Turn it on in /mods ${name}.`
201
+ if (isCovered(new Set(grants.declared), item, grants.home)) return `${name} calls ${call} without your grant to "${permissionWords(item)}". ${grantFix}`
202
202
  return `${name} calls ${call}, which needs ${declaration(item, grants.home)} in package.json "cmod".`
203
203
  }
204
204
 
@@ -22,6 +22,7 @@ export type RoutedEvent =
22
22
  | 'prompt.context'
23
23
  | 'prompt.attachment'
24
24
  | 'command.run'
25
+ | 'command.describe'
25
26
  | 'session.measure'
26
27
  | 'skill.prompt'
27
28
  | 'ui.render'
@@ -0,0 +1,80 @@
1
+ import type { Hotkey, Hotkeys } from '../hotkeys.js'
2
+ import { boundCombos, hotkeyCommand } from '../records.js'
3
+ import { ifMissing } from '../utils/paths.js'
4
+ import { messageOf } from '../utils/text.js'
5
+ import type { Claude } from './claude.js'
6
+ import type { Router } from './router.js'
7
+ import type { Failure } from './toaster.js'
8
+
9
+ export async function readCombos(claude: Claude, configRoot: string | undefined): Promise<Map<string, string>> {
10
+ if (configRoot === undefined) return new Map()
11
+ const text = await claude.fs.read(`${configRoot}/keybindings.json`).catch(ifMissing('{}'))
12
+ try {
13
+ return boundCombos(JSON.parse(text))
14
+ } catch (error) {
15
+ claude.ui.log(`${configRoot}/keybindings.json is not JSON, so cmod reads no combos from it: ${messageOf(error)}`, { to: 'debug' })
16
+ return new Map()
17
+ }
18
+ }
19
+
20
+ export async function createHotkeys({
21
+ name,
22
+ declared,
23
+ configRoot,
24
+ claude,
25
+ router,
26
+ answerInput,
27
+ report,
28
+ }: {
29
+ readonly name: string
30
+ readonly declared: Hotkeys
31
+ readonly configRoot: string | undefined
32
+ readonly claude: Claude
33
+ readonly router: Router
34
+ readonly answerInput: <Answer>(run: () => Promise<Answer>, passOn: () => Promise<unknown>) => Promise<Answer>
35
+ readonly report: (failure: Failure) => void
36
+ }): Promise<Readonly<Record<string, Hotkey>>> {
37
+ const entries = Object.entries(declared)
38
+ if (entries.length === 0) return {}
39
+ const combos = await readCombos(claude, configRoot)
40
+ const listeners = new Map<string, Set<() => unknown>>()
41
+ const commands = new Map<string, string>()
42
+ for (const [hotkey, { title }] of entries) {
43
+ listeners.set(hotkey, new Set())
44
+ claude.command.register({ name: hotkeyCommand(name, hotkey), description: title, immediate: true }).then(
45
+ ({ command }) => void commands.set(command, hotkey),
46
+ (error: unknown) => report({ id: `hotkey:${hotkey}`, message: `The ${title} hotkey is not added`, error }),
47
+ )
48
+ }
49
+ router.add('command.describe', async (e, next) => {
50
+ const described = await next(e)
51
+ return commands.has(e.command) ? { ...described, isHidden: true } : described
52
+ })
53
+ router.add('command.run', async (e, next) => {
54
+ const hotkey = commands.get(e.command)
55
+ if (hotkey === undefined) return next(e)
56
+ return answerInput(async () => {
57
+ for (const listener of listeners.get(hotkey) ?? []) {
58
+ try {
59
+ await listener()
60
+ } catch (error) {
61
+ report({ id: `hotkey:${hotkey}`, message: `The ${declared[hotkey]?.title ?? hotkey} hotkey failed`, error })
62
+ }
63
+ }
64
+ return {}
65
+ }, () => next(e))
66
+ })
67
+ return Object.fromEntries(
68
+ entries.map(([hotkey, { title }]): [string, Hotkey] => [
69
+ hotkey,
70
+ {
71
+ title,
72
+ combo: combos.get(hotkeyCommand(name, hotkey)) as Hotkey['combo'],
73
+ onPress(listener) {
74
+ listeners.get(hotkey)?.add(listener)
75
+ return { remove: () => void listeners.get(hotkey)?.delete(listener) }
76
+ },
77
+ },
78
+ ]),
79
+ )
80
+ }
@@ -2,7 +2,7 @@ import type { Args, Frozen, RenderElement, RenderPropsOf } from 'claude-code'
2
2
  import type { Step } from '../installer.js'
3
3
  import type { Mod } from '../mod.js'
4
4
  import type { Option } from '../options.js'
5
- import { permissionWords } from '../records.js'
5
+ import { permissionWords, secretFix } from '../records.js'
6
6
  import { Box, Button, drawWith, Input, Text } from '../ui/elements.js'
7
7
  import { messageOf } from '../utils/text.js'
8
8
  import type { Claude } from './claude.js'
@@ -12,7 +12,7 @@ export const installerPaneOf = (name: string): string => `installer-${name.repla
12
12
  export type Consent = {
13
13
  readonly install: string
14
14
  readonly uninstall: string
15
- readonly keys: string
15
+ readonly hotkeys: string
16
16
  readonly permissions: readonly string[]
17
17
  readonly updates?: string | undefined
18
18
  }
@@ -32,25 +32,32 @@ export type Installer = {
32
32
  close(): Promise<void>
33
33
  }
34
34
 
35
- export function createInstaller(name: string, claude: () => Claude): Installer {
35
+ export function createInstaller(name: string, claude: () => Claude, waiting: { show(open: () => Promise<void>): void; hide(): void }): Installer {
36
36
  const paneId = installerPaneOf(name)
37
37
  let page: Page | undefined
38
38
  let isOpen = false
39
39
 
40
+ const open = async () => {
41
+ isOpen = true
42
+ await claude().ui.open({ id: paneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
43
+ }
44
+
40
45
  const show = async <Answer>(build: (answer: (value: Answer) => void) => Omit<Page, 'cancel'>, cancelled: Answer): Promise<Answer> => {
41
46
  page?.cancel()
42
47
  const answer = new Promise<Answer>((resolve) => {
43
48
  const done = (value: Answer) => {
44
49
  if (page === shown) page = undefined
50
+ waiting.hide()
45
51
  resolve(value)
46
52
  }
47
53
  const shown: Page = { ...build(done), cancel: () => done(cancelled) }
48
54
  page = shown
49
55
  })
50
- if (!isOpen) {
51
- isOpen = true
52
- await claude().ui.open({ id: paneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
53
- }
56
+ waiting.show(open)
57
+ if (!isOpen) await open().catch((error: unknown) => {
58
+ waiting.hide()
59
+ throw error
60
+ })
54
61
  claude().ui.invalidate('ui.render')
55
62
  return answer
56
63
  }
@@ -70,11 +77,11 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
70
77
  isOpen = false
71
78
  page?.cancel()
72
79
  },
73
- consent({ install, uninstall, keys, permissions, updates }) {
80
+ consent({ install, uninstall, hotkeys, permissions, updates }) {
74
81
  const changes = [
75
82
  ...(install === '' ? [] : [`Run ${install} now${uninstall === '' ? '' : `, and ${uninstall} when you remove it`}`]),
76
83
  ...(install === '' && uninstall !== '' ? [`Run ${uninstall} when you remove it`] : []),
77
- ...(keys === '' ? [] : [`Bind ${keys}`]),
84
+ ...(hotkeys === '' ? [] : [`Bind ${hotkeys}`]),
78
85
  ...(updates === undefined ? [] : [`Update ${name} automatically from the ${updates} marketplace`]),
79
86
  ]
80
87
  return show<boolean>(
@@ -85,7 +92,7 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
85
92
  [
86
93
  ...(permissions.length === 0 ? [] : [Box({ flexDirection: 'column', children: permissions.map((item) => bullet(permissionWords(item))) })]),
87
94
  ...(changes.length === 0 ? [] : [Text({ children: permissions.length === 0 ? 'Change your computer:' : 'and change your computer:' }), Box({ flexDirection: 'column', children: changes.map(bullet) })]),
88
- Text({ dimColor: true, children: 'You can turn each permission off later in /mods.' }),
95
+ Text({ dimColor: true, children: 'You can turn each permission off later in Settings.' }),
89
96
  ],
90
97
  [Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'accept', hotkey: 'a', label: 'Accept', onPress: () => answer(true) })],
91
98
  ),
@@ -103,11 +110,16 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
103
110
  `${name} needs ${missing.length === 1 ? 'one setting' : `${missing.length} settings`}:`,
104
111
  missing.map(([key, option]) => {
105
112
  const label = Text({ children: [Text({ bold: true, children: option.title }), option.description === '' ? '' : Text({ dimColor: true, children: ` ${option.description}` })] })
106
- if (option.type === 'secret') return Box({ flexDirection: 'column', children: [label, Text({ dimColor: true, children: `Set it in /config, where Claude Code keeps it in your keychain, then press Next.` })] })
113
+ if (option.type === 'secret') {
114
+ return Box({
115
+ flexDirection: 'column',
116
+ children: [label, Text({ dimColor: true, children: `${secretFix} Then press Next.` }), Button({ key: `secret:${key}`, label: 'Open /config', onPress: () => void claude().command.run({ command: 'config' }) })],
117
+ })
118
+ }
107
119
  const failure = failures.get(key)
108
120
  const field = Input({
109
121
  key: `option:${key}`,
110
- label: '› ',
122
+ label: 'Value',
111
123
  placeholder: option.type === 'choice' ? (option.choices ?? []).join(', ') : option.type === 'toggle' ? 'true or false' : option.type === 'list' ? 'comma-separated' : '',
112
124
  value: saved.get(key) ?? '',
113
125
  submitLabel: 'save',