@cmodjs/core 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/docs/commands.md +50 -10
  2. package/docs/dependencies.md +12 -12
  3. package/docs/hooks.md +39 -27
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +9 -7
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +200 -174
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +248 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +65 -22
  13. package/docs/ui.md +108 -76
  14. package/jobs/check.d.ts.map +1 -1
  15. package/jobs/check.js +5 -2
  16. package/jobs/context.d.ts +7 -8
  17. package/jobs/context.d.ts.map +1 -1
  18. package/jobs/context.js +23 -73
  19. package/jobs/permissions/decide-permission.d.ts +5 -3
  20. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  21. package/jobs/permissions/decide-permission.js +6 -4
  22. package/jobs/permissions/find-project-scope.d.ts +1 -1
  23. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  24. package/jobs/permissions/find-project-scope.js +8 -11
  25. package/jobs/permissions/match-target.d.ts +2 -2
  26. package/jobs/permissions/match-target.d.ts.map +1 -1
  27. package/jobs/permissions.d.ts +1 -1
  28. package/jobs/permissions.d.ts.map +1 -1
  29. package/jobs/permissions.js +6 -4
  30. package/jobs/program.d.ts.map +1 -1
  31. package/jobs/program.js +8 -3
  32. package/jobs/prompt.d.ts +4 -2
  33. package/jobs/prompt.d.ts.map +1 -1
  34. package/jobs/prompt.js +16 -15
  35. package/jobs/schedule.d.ts +2 -2
  36. package/jobs/schedule.d.ts.map +1 -1
  37. package/jobs/schedule.js +9 -9
  38. package/jobs/slash-command.d.ts +4 -4
  39. package/jobs/slash-command.d.ts.map +1 -1
  40. package/jobs/slash-command.js +14 -9
  41. package/jobs/status-line.d.ts +1 -11
  42. package/jobs/status-line.d.ts.map +1 -1
  43. package/jobs/status-line.js +8 -12
  44. package/jobs/tool.d.ts +2 -2
  45. package/jobs/tool.d.ts.map +1 -1
  46. package/jobs/tool.js +15 -9
  47. package/mod.d.ts +154 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +94 -3
  50. package/options.d.ts +2 -2
  51. package/options.js +19 -19
  52. package/package.json +3 -3
  53. package/records.d.ts +10 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +28 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +17 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +66 -0
  61. package/runtime/bound.d.ts +4 -0
  62. package/runtime/bound.d.ts.map +1 -0
  63. package/runtime/bound.js +9 -0
  64. package/runtime/claude.d.ts +3 -2
  65. package/runtime/claude.d.ts.map +1 -1
  66. package/runtime/conversations.d.ts +16 -0
  67. package/runtime/conversations.d.ts.map +1 -0
  68. package/runtime/conversations.js +291 -0
  69. package/runtime/deadline.d.ts +0 -1
  70. package/runtime/deadline.d.ts.map +1 -1
  71. package/runtime/deadline.js +1 -1
  72. package/runtime/dependencies.d.ts +2 -0
  73. package/runtime/dependencies.d.ts.map +1 -1
  74. package/runtime/dependencies.js +9 -0
  75. package/runtime/engine.d.ts +17 -0
  76. package/runtime/engine.d.ts.map +1 -0
  77. package/runtime/engine.js +10 -0
  78. package/runtime/grants.d.ts +4 -0
  79. package/runtime/grants.d.ts.map +1 -1
  80. package/runtime/grants.js +63 -26
  81. package/runtime/hooks.d.ts +5 -4
  82. package/runtime/hooks.d.ts.map +1 -1
  83. package/runtime/hooks.js +10 -5
  84. package/runtime/installer.d.ts.map +1 -1
  85. package/runtime/installer.js +11 -9
  86. package/runtime/lifecycle.d.ts +3 -1
  87. package/runtime/lifecycle.d.ts.map +1 -1
  88. package/runtime/lifecycle.js +316 -185
  89. package/runtime/metadata.d.ts +3 -1
  90. package/runtime/metadata.d.ts.map +1 -1
  91. package/runtime/metadata.js +21 -11
  92. package/runtime/options.d.ts +3 -1
  93. package/runtime/options.d.ts.map +1 -1
  94. package/runtime/options.js +4 -4
  95. package/runtime/programs.d.ts +8 -0
  96. package/runtime/programs.d.ts.map +1 -1
  97. package/runtime/programs.js +39 -1
  98. package/runtime/router.d.ts +2 -1
  99. package/runtime/router.d.ts.map +1 -1
  100. package/runtime/router.js +11 -3
  101. package/runtime/sessions.d.ts +36 -0
  102. package/runtime/sessions.d.ts.map +1 -0
  103. package/runtime/sessions.js +235 -0
  104. package/runtime/state.d.ts +6 -2
  105. package/runtime/state.d.ts.map +1 -1
  106. package/runtime/state.js +46 -13
  107. package/runtime/toaster.d.ts +31 -0
  108. package/runtime/toaster.d.ts.map +1 -0
  109. package/runtime/toaster.js +176 -0
  110. package/runtime/ui.d.ts +6 -17
  111. package/runtime/ui.d.ts.map +1 -1
  112. package/runtime/ui.js +36 -112
  113. package/session/context/context.d.ts +10 -0
  114. package/session/context/context.d.ts.map +1 -0
  115. package/session/context/context.js +2 -0
  116. package/session/context/turn/message/document.d.ts +7 -0
  117. package/session/context/turn/message/document.d.ts.map +1 -0
  118. package/session/context/turn/message/document.js +1 -0
  119. package/session/context/turn/message/message.d.ts +54 -0
  120. package/session/context/turn/message/message.d.ts.map +1 -0
  121. package/session/context/turn/message/message.js +137 -0
  122. package/session/context/turn/message/note.d.ts +8 -0
  123. package/session/context/turn/message/note.d.ts.map +1 -0
  124. package/session/context/turn/message/note.js +1 -0
  125. package/session/context/turn/message/notice.d.ts +6 -0
  126. package/session/context/turn/message/notice.d.ts.map +1 -0
  127. package/session/context/turn/message/notice.js +1 -0
  128. package/session/context/turn/message/prompt.d.ts +8 -0
  129. package/session/context/turn/message/prompt.d.ts.map +1 -0
  130. package/session/context/turn/message/prompt.js +1 -0
  131. package/session/context/turn/message/reply.d.ts +6 -0
  132. package/session/context/turn/message/reply.d.ts.map +1 -0
  133. package/session/context/turn/message/reply.js +1 -0
  134. package/session/context/turn/message/sender.d.ts +14 -0
  135. package/session/context/turn/message/sender.d.ts.map +1 -0
  136. package/session/context/turn/message/sender.js +30 -0
  137. package/session/context/turn/message/skill.d.ts +7 -0
  138. package/session/context/turn/message/skill.d.ts.map +1 -0
  139. package/session/context/turn/message/skill.js +1 -0
  140. package/session/context/turn/message/tool-call.d.ts +9 -0
  141. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  142. package/session/context/turn/message/tool-call.js +1 -0
  143. package/session/context/turn/message/tool-result.d.ts +8 -0
  144. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  145. package/session/context/turn/message/tool-result.js +1 -0
  146. package/session/context/turn/turn.d.ts +12 -0
  147. package/session/context/turn/turn.d.ts.map +1 -0
  148. package/session/context/turn/turn.js +2 -0
  149. package/session/conversation.d.ts +64 -0
  150. package/session/conversation.d.ts.map +1 -0
  151. package/session/conversation.js +123 -0
  152. package/session/goal/goal.d.ts +10 -0
  153. package/session/goal/goal.d.ts.map +1 -0
  154. package/session/goal/goal.js +1 -0
  155. package/session/goal/task.d.ts +16 -0
  156. package/session/goal/task.d.ts.map +1 -0
  157. package/session/goal/task.js +3 -0
  158. package/session/session.d.ts +99 -0
  159. package/session/session.d.ts.map +1 -0
  160. package/session/session.js +1 -0
  161. package/src/jobs/check.ts +7 -4
  162. package/src/jobs/context.ts +24 -82
  163. package/src/jobs/permissions/decide-permission.ts +10 -8
  164. package/src/jobs/permissions/find-project-scope.ts +8 -14
  165. package/src/jobs/permissions/match-target.ts +3 -3
  166. package/src/jobs/permissions.ts +8 -6
  167. package/src/jobs/program.ts +7 -3
  168. package/src/jobs/prompt.ts +21 -24
  169. package/src/jobs/schedule.ts +14 -14
  170. package/src/jobs/slash-command.ts +20 -14
  171. package/src/jobs/status-line.ts +9 -21
  172. package/src/jobs/tool.ts +17 -11
  173. package/src/mod.ts +209 -97
  174. package/src/options.ts +22 -22
  175. package/src/records.ts +35 -23
  176. package/src/register.ts +17 -5
  177. package/src/runtime/announce.ts +74 -0
  178. package/src/runtime/bound.ts +12 -0
  179. package/src/runtime/claude.ts +4 -2
  180. package/src/runtime/conversations.ts +302 -0
  181. package/src/runtime/deadline.ts +2 -2
  182. package/src/runtime/dependencies.ts +12 -0
  183. package/src/runtime/engine.ts +22 -0
  184. package/src/runtime/grants.ts +66 -24
  185. package/src/runtime/hooks.ts +19 -10
  186. package/src/runtime/installer.ts +10 -9
  187. package/src/runtime/lifecycle.ts +312 -180
  188. package/src/runtime/metadata.ts +33 -11
  189. package/src/runtime/options.ts +19 -4
  190. package/src/runtime/programs.ts +40 -1
  191. package/src/runtime/router.ts +11 -4
  192. package/src/runtime/sessions.ts +242 -0
  193. package/src/runtime/state.ts +50 -14
  194. package/src/runtime/toaster.ts +218 -0
  195. package/src/runtime/ui.ts +42 -139
  196. package/src/session/context/context.ts +12 -0
  197. package/src/session/context/turn/message/document.ts +8 -0
  198. package/src/session/context/turn/message/message.ts +165 -0
  199. package/src/session/context/turn/message/note.ts +9 -0
  200. package/src/session/context/turn/message/notice.ts +7 -0
  201. package/src/session/context/turn/message/prompt.ts +9 -0
  202. package/src/session/context/turn/message/reply.ts +7 -0
  203. package/src/session/context/turn/message/sender.ts +38 -0
  204. package/src/session/context/turn/message/skill.ts +8 -0
  205. package/src/session/context/turn/message/tool-call.ts +10 -0
  206. package/src/session/context/turn/message/tool-result.ts +9 -0
  207. package/src/session/context/turn/turn.ts +14 -0
  208. package/src/session/conversation.ts +169 -0
  209. package/src/session/goal/goal.ts +9 -0
  210. package/src/session/goal/task.ts +19 -0
  211. package/src/session/session.ts +94 -0
  212. package/src/testing/fake-claude.ts +22 -16
  213. package/src/testing/fake-elements.ts +3 -2
  214. package/src/testing/fake-files.ts +1 -1
  215. package/src/testing/fake-sessions.ts +203 -0
  216. package/src/testing.ts +112 -33
  217. package/src/ui/components.ts +3 -3
  218. package/src/ui/elements.ts +22 -8
  219. package/src/ui/markdown.ts +22 -26
  220. package/src/ui/places.ts +49 -0
  221. package/src/utils/call-effects.ts +3 -3
  222. package/src/utils/changelog.ts +32 -0
  223. package/src/utils/matcher.ts +37 -0
  224. package/src/utils/metadata.ts +17 -15
  225. package/src/utils/parse-shell.ts +9 -8
  226. package/src/utils/paths.ts +15 -1
  227. package/src/utils/text.ts +4 -0
  228. package/testing/fake-claude.d.ts +4 -1
  229. package/testing/fake-claude.d.ts.map +1 -1
  230. package/testing/fake-claude.js +18 -11
  231. package/testing/fake-elements.js +4 -2
  232. package/testing/fake-files.js +1 -1
  233. package/testing/fake-sessions.d.ts +74 -0
  234. package/testing/fake-sessions.d.ts.map +1 -0
  235. package/testing/fake-sessions.js +170 -0
  236. package/testing.d.ts +33 -13
  237. package/testing.d.ts.map +1 -1
  238. package/testing.js +92 -21
  239. package/ui/components.d.ts +2 -3
  240. package/ui/components.d.ts.map +1 -1
  241. package/ui/components.js +3 -3
  242. package/ui/elements.d.ts.map +1 -1
  243. package/ui/elements.js +19 -9
  244. package/ui/markdown.d.ts +35 -33
  245. package/ui/markdown.d.ts.map +1 -1
  246. package/ui/markdown.js +14 -16
  247. package/ui/places.d.ts +43 -0
  248. package/ui/places.d.ts.map +1 -0
  249. package/ui/places.js +31 -0
  250. package/utils/call-effects.d.ts +1 -1
  251. package/utils/call-effects.d.ts.map +1 -1
  252. package/utils/call-effects.js +2 -2
  253. package/utils/changelog.d.ts +9 -0
  254. package/utils/changelog.d.ts.map +1 -0
  255. package/utils/changelog.js +29 -0
  256. package/utils/matcher.d.ts +13 -0
  257. package/utils/matcher.d.ts.map +1 -0
  258. package/utils/matcher.js +32 -0
  259. package/utils/metadata.d.ts +3 -3
  260. package/utils/metadata.js +18 -14
  261. package/utils/parse-shell.js +9 -7
  262. package/utils/paths.d.ts +2 -0
  263. package/utils/paths.d.ts.map +1 -1
  264. package/utils/paths.js +13 -1
  265. package/utils/text.d.ts +1 -0
  266. package/utils/text.d.ts.map +1 -1
  267. package/utils/text.js +3 -0
  268. package/src/ui/slots.ts +0 -36
  269. package/ui/slots.d.ts +0 -208
  270. package/ui/slots.d.ts.map +0 -1
  271. package/ui/slots.js +0 -19
package/src/register.ts CHANGED
@@ -3,6 +3,7 @@ import type { ModDefinition } from './mod.js'
3
3
  import type { Options } from './options.js'
4
4
  import type { Claude } from './runtime/claude.js'
5
5
  import type { RoutedEvent } from './runtime/hooks.js'
6
+ import { cmodPluginName } from './runtime/dependencies.js'
6
7
  import { createLifecycle, readPlugin, type Lifecycle } from './runtime/lifecycle.js'
7
8
 
8
9
  let lifecycle: Lifecycle<object>
@@ -19,7 +20,6 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
19
20
  const claude: Claude = {
20
21
  plugin: { name: $.plugin.name, root: $.plugin.root },
21
22
  ui: {
22
- toast: (text, options) => $.ui.toast(text, options),
23
23
  status: (text) => $.ui.status(text),
24
24
  log: (text, options) => $.ui.log(text, options),
25
25
  notice: (toolUseId, text) => $.ui.notice(toolUseId, text),
@@ -29,6 +29,7 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
29
29
  close: (pane) => $.ui.close(pane),
30
30
  panes: () => $.ui.panes(),
31
31
  scroll: (args) => $.ui.scroll(args),
32
+ copy: (args) => $.ui.copy(args),
32
33
  resolve: (render) => $.ui.resolve(render),
33
34
  },
34
35
  process: {
@@ -65,11 +66,12 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
65
66
  surfaces: () => $.session.surfaces(),
66
67
  messages: ((args?: Parameters<Claude['session']['messages']>[0]) => (args === undefined ? $.session.messages() : $.session.messages(args))) as Claude['session']['messages'],
67
68
  append: (args) => $.session.append(args),
69
+ send: (args) => $.session.send(args),
68
70
  },
69
71
  prompt: { submit: (args) => $.prompt.submit(args) },
70
72
  model: { complete: (request, options) => $.model.complete(request, options) },
71
73
  command: { register: (command) => $.command.register(command) },
72
- tool: { register: (tool) => $.tool.register(tool), call: ((input: ToolCallArgs) => $.tool.call(input)) as Claude['tool']['call'] },
74
+ tool: { register: (tool) => $.tool.register(tool), call: (input: ToolCallArgs) => $.tool.call(input) },
73
75
  agent: {
74
76
  list: () => $.agent.list(),
75
77
  spawn: (args) => $.agent.spawn(args),
@@ -81,14 +83,19 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
81
83
  },
82
84
  cmod: { call: (input) => $.cmod.call(input) },
83
85
  }
84
- await lifecycle.start(claude, readPlugin)
85
- return passOn(eventInput)
86
+ const [, answer] = await Promise.all([lifecycle.start(claude, readPlugin), passOn(eventInput)])
87
+ return answer
86
88
  }
87
89
 
88
90
  function routeToMod<N extends RoutedEvent>(_$: EngineInterface, eventInput: Frozen<Args<N>>, passOn: Next<N>): Promise<EventResult<N>> {
89
91
  return lifecycle.route(passOn.event, eventInput, (passed: unknown) => passOn(passed as Args<N>) as Promise<EventResult<N>>)
90
92
  }
91
93
 
94
+ function routeCallToMod(_$: EngineInterface, eventInput: Frozen<Args<'cmod.call'>>, passOn: Next<'cmod.call'>): Promise<EventResult<'cmod.call'>> {
95
+ const called = { ...eventInput, caller: passOn.origin.plugin } as Frozen<Args<'cmod.call'>>
96
+ return lifecycle.route('cmod.call', called, (passed) => passOn(Object.fromEntries(Object.entries(passed).filter(([key]) => key !== 'caller')) as Args<'cmod.call'>))
97
+ }
98
+
92
99
  export function registerMod<State extends object, Declared extends Options>(addHook: On, definition: ModDefinition<State, string, Declared>, options: PluginOptions): void {
93
100
  checksPermissions = false
94
101
  registered = definition as ModDefinition<object>
@@ -119,7 +126,12 @@ export function registerMod<State extends object, Declared extends Options>(addH
119
126
  addHook('ui.press', routeToMod)
120
127
  addHook('ui.scroll', routeToMod)
121
128
  addHook('ui.close', routeToMod)
122
- addHook('cmod.call', routeToMod)
129
+ addHook('cmod.call', routeCallToMod)
130
+ if (definition.name !== cmodPluginName) return
131
+ addHook('session.append', routeToMod)
132
+ addHook('session.compact', routeToMod)
133
+ addHook('session.receive', routeToMod)
134
+ addHook('agent.spawn', routeToMod)
123
135
  }
124
136
 
125
137
  export function registerPermissionCheck(addHook: On): void {
@@ -0,0 +1,74 @@
1
+ import { changelogPage, isObject, marketplaceOf, permissionWords } from '../records.js'
2
+ import { changeLines, changelogSections, unreleased } from '../utils/changelog.js'
3
+ import { ifMissing } from '../utils/paths.js'
4
+ import { listed } from '../utils/text.js'
5
+ import type { Claude } from './claude.js'
6
+ import { cmodPluginName } from './dependencies.js'
7
+ import type { Plugin } from './lifecycle.js'
8
+
9
+ export type Announced = { readonly version: string; readonly added: readonly string[] }
10
+
11
+ export type Announcement = { readonly message: string; readonly description?: string; readonly action?: { readonly label: string; readonly onClick: () => unknown } }
12
+
13
+ export const announcementMs = 10_000
14
+
15
+ const shownChanges = 3
16
+
17
+ export function announcedOf(saved: unknown): Announced | undefined {
18
+ if (typeof saved === 'string') return { version: saved, added: [] }
19
+ if (!isObject(saved) || typeof saved['version'] !== 'string') return undefined
20
+ return { version: saved['version'], added: Array.isArray(saved['added']) ? saved['added'].map(String) : [] }
21
+ }
22
+
23
+ export async function announcementOf(claude: Claude, plugin: Plugin, granted: ReadonlySet<string>, added: readonly string[], previous: Announced | undefined): Promise<Announcement> {
24
+ const read = (path: string) => claude.fs.read(`${plugin.root}/${path}`).catch(ifMissing(undefined))
25
+ const manifest = jsonOf(await read('.claude-plugin/plugin.json'))
26
+ 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 }) } })
28
+ if (previous === undefined) {
29
+ const { program, keys } = plugin.steps
30
+ const lines = [
31
+ field('description'),
32
+ added.length > 0 ? `Adds ${listed(added)}.` : undefined,
33
+ program === undefined ? undefined : `Installed the ${program} program.`,
34
+ ...Object.entries(keys ?? {}).map(([key, command]) => `Binds ${key} to /${command}.`),
35
+ ...[...granted].map((item) => `${permissionWords(item)}.`),
36
+ ]
37
+ return { message: 'Installed', ...described(lines), action: { label: 'Settings', onClick: open() } }
38
+ }
39
+ const version = plugin.version ?? ''
40
+ const isCheckout = marketplaceOf(plugin.root, plugin.name) === undefined
41
+ const sections = changelogSections((await read('CHANGELOG.md')) ?? '')
42
+ const section = sections.find((each) => each.version === version) ?? (isCheckout ? sections.find((each) => each.version === unreleased) : undefined)
43
+ const changes = section === undefined ? [] : changeLines(section.text)
44
+ const nowAdded = added.filter((each) => !previous.added.includes(each))
45
+ const gone = previous.added.filter((each) => !added.includes(each))
46
+ const fallback = previous.added.length === 0 ? [] : [nowAdded.length > 0 ? `Now adds ${listed(nowAdded)}.` : undefined, gone.length > 0 ? `No longer adds ${listed(gone)}.` : undefined]
47
+ const home = await claude.env.home()
48
+ const folder = home !== undefined && plugin.root.startsWith(`${home}/`) ? `~${plugin.root.slice(home.length)}` : plugin.root
49
+ const repository = field('repository')
50
+ const lines = [
51
+ ...(changes.length === 0 ? fallback : changes.length > shownChanges ? [...changes.slice(0, shownChanges), '…'] : changes),
52
+ `From ${previous.version}${isCheckout ? `, loaded from ${folder}` : ''}.`,
53
+ repository === undefined || isCheckout ? undefined : `Release notes: ${repository.replace(/\/$/, '')}/releases/tag/v${version}`,
54
+ ]
55
+ return {
56
+ message: `Updated to ${version}`,
57
+ ...described(lines),
58
+ ...(sections.length > 0 ? { action: { label: 'Changelog', onClick: open(changelogPage) } } : {}),
59
+ }
60
+ }
61
+
62
+ function jsonOf(text: string | undefined): unknown {
63
+ try {
64
+ return text === undefined ? undefined : JSON.parse(text)
65
+ } catch (error) {
66
+ if (error instanceof SyntaxError) return undefined
67
+ throw error
68
+ }
69
+ }
70
+
71
+ function described(lines: readonly (string | undefined)[]): { readonly description?: string } {
72
+ const kept = lines.filter((line) => line !== undefined)
73
+ return kept.length === 0 ? {} : { description: kept.join('\n') }
74
+ }
@@ -0,0 +1,12 @@
1
+ import type { Imports } from '../mod.js'
2
+
3
+ let current: Imports | undefined
4
+
5
+ export function bind(imports: Imports): void {
6
+ current = imports
7
+ }
8
+
9
+ export function bound(): Imports {
10
+ if (current === undefined) throw new Error("A mod's imports work once its setup runs. Call them from setup or from a hook, never at the top of a module.")
11
+ return current
12
+ }
@@ -28,8 +28,9 @@ import type {
28
28
  Settings,
29
29
  SettingsReadArgs,
30
30
  Timer,
31
- ToastOptions,
32
31
  ToolSpec,
32
+ UiCopyArgs,
33
+ UiCopyResult,
33
34
  UiLogOptions,
34
35
  UiOpenResult,
35
36
  UiPane,
@@ -40,7 +41,6 @@ import type {
40
41
  export type Claude = {
41
42
  readonly plugin: { readonly name: string; readonly root: string }
42
43
  readonly ui: {
43
- toast(text: string, options?: ToastOptions): void
44
44
  status(text: string | undefined): void
45
45
  log(text: string, options?: UiLogOptions): void
46
46
  notice(toolUseId: string, text: string | undefined): void
@@ -50,6 +50,7 @@ export type Claude = {
50
50
  close(pane: PaneCloseArgs): Promise<void>
51
51
  panes(): Promise<readonly UiPane[]>
52
52
  scroll(args: UiScrollArgs): Promise<UiScrollResult>
53
+ copy(args: UiCopyArgs): Promise<UiCopyResult>
53
54
  resolve(e: ResolveInput): ElementTable<RenderSurface>
54
55
  }
55
56
  readonly process: {
@@ -86,6 +87,7 @@ export type Claude = {
86
87
  surfaces(): Promise<readonly RenderSurface[]>
87
88
  messages: EngineInterface['session']['messages']
88
89
  append: EngineInterface['session']['append']
90
+ send: EngineInterface['session']['send']
89
91
  }
90
92
  readonly prompt: { submit: EngineInterface['prompt']['submit'] }
91
93
  readonly model: { complete: EngineInterface['model']['complete'] }
@@ -0,0 +1,302 @@
1
+ import type { Args, EventResult, Frozen } from 'claude-code'
2
+ import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
3
+ import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
4
+ import { Note } from '../session/context/turn/message/note.js'
5
+ import { Prompt } from '../session/context/turn/message/prompt.js'
6
+ import { Person } from '../session/context/turn/message/sender.js'
7
+ import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
8
+ import { receivedMatcher } from '../utils/matcher.js'
9
+ import { ifMissing } from '../utils/paths.js'
10
+ import { formatExit, messageOf } from '../utils/text.js'
11
+ import type { Claude } from './claude.js'
12
+ import { cmodPluginName } from './dependencies.js'
13
+ import { readLines, runCmod } from './programs.js'
14
+ import type { Router } from './router.js'
15
+ import { conversationMethods, isHeardBy, type Subscription } from './sessions.js'
16
+
17
+ type Request = Readonly<Record<string, unknown>>
18
+
19
+ type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
20
+
21
+ const delivery = /^<cmod-delivery>([\s\S]*)<\/cmod-delivery>$/
22
+
23
+ const bufferedTaskRows = 200
24
+
25
+ export type Conversations = {
26
+ answer(e: Frozen<Args<'cmod.call'>> & { readonly caller?: string }): Promise<EventResult<'cmod.call'> | undefined>
27
+ }
28
+
29
+ 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 {
30
+ const goals = `${dataFolder(store, cmodPluginName)}/goals`
31
+ const heard = new Map<string, Map<string, readonly Heard[]>>()
32
+ let session = ''
33
+ let conversation: Conversation = createConversation(emptyConversation(''))
34
+ let starting: Promise<void> = Promise.resolve()
35
+ let live: Message[] = []
36
+ const liveIds = new Set<string>()
37
+ const waitingRows = new Map<string, Row[]>()
38
+ let follower: { readonly sessions: string; stop(): void } | undefined
39
+
40
+ const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], session === '' ? await claude.session.id() : session)) as Value
41
+
42
+ const isGranted = async (caller: string | undefined, item: string) => {
43
+ if (caller === cmodPluginName) return true
44
+ if (caller === undefined) return false
45
+ const path = consentPath(store)
46
+ const text = await claude.fs.read(path).catch(ifMissing('{}'))
47
+ return (parseConsent(JSON.parse(text), path)[caller] ?? []).includes(item)
48
+ }
49
+
50
+ const dispatch = (to: string, changes: readonly SessionChange[]) => {
51
+ for (const change of changes) {
52
+ if (change.type === Message && !liveIds.has(change.message.id) && to === session) {
53
+ live.push(change.message)
54
+ liveIds.add(change.message.id)
55
+ }
56
+ for (const [mod, sessions] of heard) {
57
+ const subscriptions = sessions.get(to) ?? []
58
+ if (!subscriptions.some(({ event, matcher }) => isHeardBy(event, matcher, change))) continue
59
+ void claude.cmod.call({ to: mod, method: conversationMethods.changed, input: { session: to, change } }).catch((error: unknown) => report(`changed:${mod}`, `Could not tell ${mod} about session ${to}`, error))
60
+ }
61
+ }
62
+ }
63
+
64
+ const restartFollower = () => {
65
+ const others = [...new Set([...heard.values()].flatMap((sessions) => [...sessions].filter(([id, list]) => id !== session && list.length > 0).map(([id]) => id)))].sort()
66
+ const wanted = others.join(' ')
67
+ if (follower?.sessions === wanted) return
68
+ follower?.stop()
69
+ follower = undefined
70
+ if (others.length === 0) return
71
+ const stream = claude.process.spawn({ argv: ['cmod', 'history', 'follow', ...others, '--json', '--project', project()], env: { CLAUDE_CODE_SESSION_ID: session } })
72
+ const started = { sessions: wanted, stop: () => void stream.return(undefined as never) }
73
+ follower = started
74
+ void readLines(stream, (line) => {
75
+ if (line.trim() === '') return
76
+ const parsed = JSON.parse(line) as { readonly session: string; readonly change: SessionChange }
77
+ dispatch(parsed.session, [parsed.change])
78
+ })
79
+ .then(({ code, lastError }) => {
80
+ if (follower === started) throw new Error(`cmod history follow ${formatExit(code, lastError)}`)
81
+ })
82
+ .catch((error: unknown) => {
83
+ if (follower !== started) return
84
+ follower = undefined
85
+ report('follow', 'Stopped hearing the other sessions', error)
86
+ })
87
+ }
88
+
89
+ const start = (id: string, source: string) => {
90
+ if (id === session && source !== 'compact') return
91
+ if (id === session && source === 'compact') {
92
+ dispatch(session, conversation.startContext(new Date().toISOString()))
93
+ return
94
+ }
95
+ session = id
96
+ live = []
97
+ liveIds.clear()
98
+ waitingRows.clear()
99
+ conversation = createConversation(emptyConversation(id))
100
+ starting =
101
+ source === 'startup' || source === 'clear'
102
+ ? Promise.resolve(dispatch(id, conversation.startContext(new Date().toISOString())))
103
+ : json<ConversationState>(['history', 'state', id]).then(
104
+ (state) => {
105
+ if (session === id) conversation = createConversation(state)
106
+ },
107
+ (error: unknown) => report('state', `Starts session ${id} without its earlier turns`, error),
108
+ )
109
+ restartFollower()
110
+ }
111
+
112
+ const take = async (row: Row, task: string | undefined) => {
113
+ await starting
114
+ if (task !== undefined && conversation.state.tasks[task] === undefined) {
115
+ const waiting = waitingRows.get(task) ?? []
116
+ waiting.push(row)
117
+ waitingRows.set(task, waiting)
118
+ if (waiting.length < bufferedTaskRows) return
119
+ waitingRows.delete(task)
120
+ dispatch(session, conversation.startTask({ id: task, title: '', agentType: 'general-purpose', openedBy: '', openedAt: row.at }))
121
+ for (const each of waiting) dispatch(session, conversation.add(each, task))
122
+ return
123
+ }
124
+ dispatch(session, conversation.add(row, task))
125
+ }
126
+
127
+ router.add('classic.SessionStart', async (e, next) => {
128
+ start(e.session_id, e.source)
129
+ return next(e)
130
+ })
131
+
132
+ router.add('session.append', async (e, next) => {
133
+ const kept = await next(e)
134
+ if (kept.deny !== undefined || session === '') return kept
135
+ const cwd = e.message.type === 'assistant' ? await claude.session.cwd() : ''
136
+ void take(rowOfAppend({ uuid: kept.uuid, door: e.door, origin: e.origin, message: kept.message }, new Date().toISOString(), cwd), e.agentId).catch((error: unknown) => report('append', 'Missed a message', error))
137
+ return kept
138
+ })
139
+
140
+ router.add('session.compact', async (e, next) => {
141
+ const compacted = await next(e)
142
+ if (e.agentId === undefined && e.trigger !== 'precompute' && compacted.skip === undefined) {
143
+ await starting
144
+ dispatch(session, conversation.endContext(new Date().toISOString()))
145
+ }
146
+ return compacted
147
+ })
148
+
149
+ router.add('agent.spawn', async (e, next) => {
150
+ const spawned = await next(e)
151
+ if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
152
+ const id = spawned.agentId
153
+ const opened: TaskData = { id, title: e.description, agentType: e.subagentType, model: spawned.model, openedBy: e.tool_use_id, openedAt: new Date().toISOString(), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }) }
154
+ await starting
155
+ dispatch(session, conversation.startTask(opened))
156
+ const waiting = waitingRows.get(id) ?? []
157
+ waitingRows.delete(id)
158
+ for (const row of waiting) dispatch(session, conversation.add(row, id))
159
+ return spawned
160
+ })
161
+
162
+ router.add('session.receive', async (e, next) => {
163
+ const isFromCmod = e.origin.kind === 'peer' && !('teammate' in e.origin) && e.origin.plugin === cmodPluginName
164
+ const sent = isFromCmod ? delivery.exec(e.text)?.[1] : undefined
165
+ if (sent === undefined) return next(e)
166
+ const parsed: unknown = JSON.parse(sent)
167
+ if (!isObject(parsed) || typeof parsed['text'] !== 'string') return next(e)
168
+ const { type, text, as } = parsed
169
+ claude.clock.after(0, () => {
170
+ const delivered = type === Note ? claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }) : claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
171
+ void delivered.catch((error: unknown) => report('delivery', 'Could not deliver a message from another session', error))
172
+ })
173
+ return { consumed: 'cmod delivery' }
174
+ })
175
+
176
+ const merged = <Item>(saved: readonly Item[], kept: readonly Item[], key: (item: Item) => string): Item[] => {
177
+ const keptKeys = new Set(kept.map(key))
178
+ return [...saved.filter((item) => !keptKeys.has(key(item))), ...kept]
179
+ }
180
+
181
+ const liveTurns = (): TurnData[] => {
182
+ const turn = conversation.state.turn
183
+ return turn === undefined ? [] : [turn]
184
+ }
185
+
186
+ const liveTasks = (): TaskData[] => Object.values(conversation.state.tasks).map(({ context: _context, ...task }) => task)
187
+
188
+ const liveContext = (): ContextData[] => {
189
+ const { context, contextOpenedAt } = conversation.state
190
+ return context === 0 ? [] : [{ number: context, openedAt: contextOpenedAt }]
191
+ }
192
+
193
+ const lastOf = <Item>(items: readonly Item[], last: unknown): Item[] => (typeof last === 'number' ? items.slice(-last) : [...items])
194
+
195
+ const methods: Readonly<Record<string, (request: Request, caller: string | undefined) => Promise<unknown>>> = {
196
+ async [conversationMethods.sessions](request) {
197
+ const found = await json<SessionData[]>(['history', 'sessions', ...scopeArgs(request), ...(request['running'] === true ? ['--running'] : [])])
198
+ return found.map((each) => (each.id === session ? { ...each, isRunning: true } : each))
199
+ },
200
+ async [conversationMethods.search](request) {
201
+ const before = isObject(request['before']) ? request['before'] : undefined
202
+ return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...(request['running'] === true ? ['--running'] : []), ...countArg('--limit', request['limit']), ...(before === undefined ? [] : ['--before', `${String(before['session'])}:${String(before['id'])}`])])
203
+ },
204
+ async [conversationMethods.messages](request) {
205
+ const id = String(request['session'])
206
+ const filters = [...textArg('--turn', request['turn']), ...textArg('--task', request['task']), ...textArg('--after', request['after'])]
207
+ const saved = await json<Message[]>(['history', 'messages', id, ...filters, ...countArg('--last', request['last'])])
208
+ if (id !== session) return saved
209
+ await starting
210
+ const fits = (message: Message) => (request['turn'] === undefined || message.turn === request['turn']) && message.task === request['task']
211
+ const after = typeof request['after'] === 'string' ? live.findIndex((message) => message.id === request['after']) : -1
212
+ const kept = live.slice(after + 1).filter(fits)
213
+ return lastOf(merged(saved, kept, (message) => message.id), request['last'])
214
+ },
215
+ async [conversationMethods.turns](request) {
216
+ const id = String(request['session'])
217
+ const saved = await json<TurnData[]>(['history', 'turns', id, ...countArg('--context', request['context']), ...countArg('--last', request['last'])])
218
+ if (id !== session) return saved
219
+ await starting
220
+ const kept = liveTurns().filter((turn) => request['context'] === undefined || turn.context === request['context'])
221
+ return lastOf(merged(saved, kept, (turn) => turn.prompt.id), request['last'])
222
+ },
223
+ async [conversationMethods.contexts](request) {
224
+ const id = String(request['session'])
225
+ const saved = await json<ContextData[]>(['history', 'contexts', id])
226
+ if (id !== session) return saved
227
+ await starting
228
+ return merged(saved, liveContext(), (context) => String(context.number))
229
+ },
230
+ async [conversationMethods.tasks](request) {
231
+ const id = String(request['session'])
232
+ const saved = await json<TaskData[]>(['history', 'tasks', id, ...textArg('--task', request['task'])])
233
+ if (id !== session) return saved
234
+ await starting
235
+ const kept = liveTasks().filter((task) => task.parent === request['task'])
236
+ return merged(saved, kept, (task) => task.id)
237
+ },
238
+ async [conversationMethods.goal](request) {
239
+ const id = String(request['session'])
240
+ if (id === session) {
241
+ await starting
242
+ const text = conversation.state.goal
243
+ if (text !== undefined) return { text }
244
+ }
245
+ return json(['history', 'goal', id])
246
+ },
247
+ async [conversationMethods.updateGoal](request) {
248
+ const id = String(request['session'])
249
+ const text = String(request['text'])
250
+ await claude.fs.write(`${goals}/${id}.txt`, text)
251
+ if (id === session) {
252
+ await starting
253
+ dispatch(session, conversation.changeGoal(text))
254
+ }
255
+ return null
256
+ },
257
+ async [conversationMethods.subscribe](request, caller) {
258
+ if (caller === undefined) throw new Error('cmod hears no caller for this subscription.')
259
+ const id = String(request['session'])
260
+ const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
261
+ const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
262
+ sessions.set(id, list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
263
+ heard.set(caller, sessions)
264
+ restartFollower()
265
+ return null
266
+ },
267
+ async [conversationMethods.send](request, caller) {
268
+ if (!(await isGranted(caller, 'prompt'))) throw new Error(`${caller ?? 'A mod'} sends to another session without your grant to send prompts.`)
269
+ const type = request['type'] === Note ? Note : Prompt
270
+ const sent = await claude.session.send({ to: { sessionId: String(request['session']) }, text: `<cmod-delivery>${JSON.stringify({ type, text: request['text'], as: request['as'] })}</cmod-delivery>` })
271
+ if (sent.isDelivered !== true) throw new Error(`Claude Code could not deliver the message to session ${String(request['session'])}.`)
272
+ return null
273
+ },
274
+ }
275
+
276
+ return {
277
+ async answer(e) {
278
+ const method = methods[e.method]
279
+ if (method === undefined) return undefined
280
+ const input = isObject(e.input) ? e.input : {}
281
+ if (e.method !== conversationMethods.send && !(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() }
282
+ try {
283
+ return { value: await method(input, e.caller) }
284
+ } catch (error) {
285
+ return { deny: messageOf(error) }
286
+ }
287
+ },
288
+ }
289
+ }
290
+
291
+ function scopeArgs(request: Request): string[] {
292
+ return [...(typeof request['since'] === 'string' ? ['--since', request['since']] : []), ...(request['everyProject'] === true ? ['--all'] : [])]
293
+ }
294
+
295
+ function countArg(flag: string, value: unknown): string[] {
296
+ return typeof value === 'number' ? [flag, String(value)] : []
297
+ }
298
+
299
+ function textArg(flag: string, value: unknown): string[] {
300
+ return typeof value === 'string' ? [flag, value] : []
301
+ }
302
+
@@ -1,10 +1,10 @@
1
1
  import type { Claude } from './claude.js'
2
2
 
3
- export type Deadline = { readonly ms: number; readonly job?: string; readonly longestMs?: number }
3
+ export type Deadline = { readonly ms: number; readonly job?: string }
4
4
 
5
5
  export const longestMs = 600_000
6
6
 
7
- export const toolDeadline: Deadline = { ms: 30000, longestMs, job: 'tool' }
7
+ export const toolDeadline: Deadline = { ms: 30000, job: 'tool' }
8
8
 
9
9
  export function beforeDeadline<Value>(claude: Claude, { ms, job }: Deadline, call: string, task: Promise<Value>): Promise<Value> {
10
10
  return new Promise<Value>((resolve, reject) => {
@@ -2,6 +2,8 @@ import type { Args, CmodDependencies, EventResult, Frozen } from 'claude-code'
2
2
  import { messageOf } from '../utils/text.js'
3
3
  import type { Claude } from './claude.js'
4
4
 
5
+ export const cmodPluginName = 'cmod'
6
+
5
7
  export async function answerCall(name: string, api: { readonly [method: string]: (input: never) => unknown }, e: Frozen<Args<'cmod.call'>>): Promise<EventResult<'cmod.call'>> {
6
8
  const method = Object.hasOwn(api, e.method) ? api[e.method] : undefined
7
9
  if (method === undefined) return { deny: `${name} has no method ${e.method}.` }
@@ -21,6 +23,16 @@ export function dependencyCalls(claude: Claude, within: (call: string, task: Pro
21
23
  return new Proxy({}, { get: (_dependencies, to) => (typeof to === 'string' && to !== 'then' ? methodsOf(to) : undefined) }) as CmodDependencies
22
24
  }
23
25
 
26
+ export function withBareRefusals(claude: Claude): Claude {
27
+ const prefix = `${claude.plugin.name}: $.cmod.call: `
28
+ const call: Claude['cmod']['call'] = (input) =>
29
+ claude.cmod.call(input).catch((error: unknown) => {
30
+ const message = messageOf(error)
31
+ throw message.startsWith(prefix) ? new Error(message.slice(prefix.length), { cause: error }) : error
32
+ })
33
+ return { ...claude, cmod: { call } }
34
+ }
35
+
24
36
  export function notInstalled(name: string): string {
25
37
  return `${name} is not installed. Run cmod install ${name}.`
26
38
  }
@@ -0,0 +1,22 @@
1
+ import type { Claude } from './claude.js'
2
+ import type { Router } from './router.js'
3
+ import type { ToolCalls } from './tool-calls.js'
4
+
5
+ export type Engine = {
6
+ readonly claude: Claude
7
+ readonly router: Router
8
+ readonly toolCalls: ToolCalls
9
+ readonly report: (failure: { readonly id: string; readonly message: string; readonly error?: unknown; readonly description?: string }) => void
10
+ }
11
+
12
+ const engines = new WeakMap<object, Engine>()
13
+
14
+ export function keepEngine(mod: object, engine: Engine): void {
15
+ engines.set(mod, engine)
16
+ }
17
+
18
+ export function engineOf(mod: object): Engine {
19
+ const engine = engines.get(mod)
20
+ if (engine === undefined) throw new Error('This mod has not started, so it reaches no Claude Code calls yet.')
21
+ return engine
22
+ }