@cmodjs/core 0.3.0 → 1.5.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 (280) hide show
  1. package/docs/commands.md +61 -10
  2. package/docs/dependencies.md +16 -14
  3. package/docs/hooks.md +64 -29
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +13 -9
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +227 -175
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +311 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +68 -23
  13. package/docs/ui.md +116 -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 +14 -4
  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 +178 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +127 -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 +13 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +33 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +34 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +68 -0
  61. package/runtime/before.d.ts +32 -0
  62. package/runtime/before.d.ts.map +1 -0
  63. package/runtime/before.js +214 -0
  64. package/runtime/bound.d.ts +4 -0
  65. package/runtime/bound.d.ts.map +1 -0
  66. package/runtime/bound.js +9 -0
  67. package/runtime/claude.d.ts +4 -2
  68. package/runtime/claude.d.ts.map +1 -1
  69. package/runtime/conversations.d.ts +17 -0
  70. package/runtime/conversations.d.ts.map +1 -0
  71. package/runtime/conversations.js +563 -0
  72. package/runtime/deadline.d.ts +0 -1
  73. package/runtime/deadline.d.ts.map +1 -1
  74. package/runtime/deadline.js +1 -1
  75. package/runtime/dependencies.d.ts +2 -0
  76. package/runtime/dependencies.d.ts.map +1 -1
  77. package/runtime/dependencies.js +10 -1
  78. package/runtime/engine.d.ts +13 -0
  79. package/runtime/engine.d.ts.map +1 -0
  80. package/runtime/engine.js +10 -0
  81. package/runtime/grants.d.ts +4 -0
  82. package/runtime/grants.d.ts.map +1 -1
  83. package/runtime/grants.js +68 -31
  84. package/runtime/hooks.d.ts +5 -4
  85. package/runtime/hooks.d.ts.map +1 -1
  86. package/runtime/hooks.js +29 -9
  87. package/runtime/installer.d.ts +1 -1
  88. package/runtime/installer.d.ts.map +1 -1
  89. package/runtime/installer.js +17 -14
  90. package/runtime/lifecycle.d.ts +3 -1
  91. package/runtime/lifecycle.d.ts.map +1 -1
  92. package/runtime/lifecycle.js +414 -212
  93. package/runtime/metadata.d.ts +3 -1
  94. package/runtime/metadata.d.ts.map +1 -1
  95. package/runtime/metadata.js +25 -14
  96. package/runtime/options.d.ts +3 -1
  97. package/runtime/options.d.ts.map +1 -1
  98. package/runtime/options.js +4 -4
  99. package/runtime/programs.d.ts +8 -0
  100. package/runtime/programs.d.ts.map +1 -1
  101. package/runtime/programs.js +39 -1
  102. package/runtime/router.d.ts +2 -1
  103. package/runtime/router.d.ts.map +1 -1
  104. package/runtime/router.js +11 -3
  105. package/runtime/sessions.d.ts +58 -0
  106. package/runtime/sessions.d.ts.map +1 -0
  107. package/runtime/sessions.js +433 -0
  108. package/runtime/state.d.ts +6 -2
  109. package/runtime/state.d.ts.map +1 -1
  110. package/runtime/state.js +46 -13
  111. package/runtime/toaster.d.ts +24 -0
  112. package/runtime/toaster.d.ts.map +1 -0
  113. package/runtime/toaster.js +286 -0
  114. package/runtime/ui.d.ts +6 -17
  115. package/runtime/ui.d.ts.map +1 -1
  116. package/runtime/ui.js +62 -118
  117. package/session/context/context.d.ts +12 -0
  118. package/session/context/context.d.ts.map +1 -0
  119. package/session/context/context.js +2 -0
  120. package/session/context/turn/message/document.d.ts +7 -0
  121. package/session/context/turn/message/document.d.ts.map +1 -0
  122. package/session/context/turn/message/document.js +1 -0
  123. package/session/context/turn/message/message.d.ts +55 -0
  124. package/session/context/turn/message/message.d.ts.map +1 -0
  125. package/session/context/turn/message/message.js +178 -0
  126. package/session/context/turn/message/note.d.ts +8 -0
  127. package/session/context/turn/message/note.d.ts.map +1 -0
  128. package/session/context/turn/message/note.js +1 -0
  129. package/session/context/turn/message/notice.d.ts +6 -0
  130. package/session/context/turn/message/notice.d.ts.map +1 -0
  131. package/session/context/turn/message/notice.js +1 -0
  132. package/session/context/turn/message/prompt.d.ts +8 -0
  133. package/session/context/turn/message/prompt.d.ts.map +1 -0
  134. package/session/context/turn/message/prompt.js +1 -0
  135. package/session/context/turn/message/reply.d.ts +6 -0
  136. package/session/context/turn/message/reply.d.ts.map +1 -0
  137. package/session/context/turn/message/reply.js +1 -0
  138. package/session/context/turn/message/sender.d.ts +14 -0
  139. package/session/context/turn/message/sender.d.ts.map +1 -0
  140. package/session/context/turn/message/sender.js +30 -0
  141. package/session/context/turn/message/skill.d.ts +7 -0
  142. package/session/context/turn/message/skill.d.ts.map +1 -0
  143. package/session/context/turn/message/skill.js +1 -0
  144. package/session/context/turn/message/tool-call.d.ts +9 -0
  145. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  146. package/session/context/turn/message/tool-call.js +1 -0
  147. package/session/context/turn/message/tool-result.d.ts +8 -0
  148. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  149. package/session/context/turn/message/tool-result.js +1 -0
  150. package/session/context/turn/turn.d.ts +13 -0
  151. package/session/context/turn/turn.d.ts.map +1 -0
  152. package/session/context/turn/turn.js +2 -0
  153. package/session/conversation.d.ts +73 -0
  154. package/session/conversation.d.ts.map +1 -0
  155. package/session/conversation.js +146 -0
  156. package/session/goal/goal.d.ts +11 -0
  157. package/session/goal/goal.d.ts.map +1 -0
  158. package/session/goal/goal.js +1 -0
  159. package/session/goal/task.d.ts +16 -0
  160. package/session/goal/task.d.ts.map +1 -0
  161. package/session/goal/task.js +3 -0
  162. package/session/session.d.ts +158 -0
  163. package/session/session.d.ts.map +1 -0
  164. package/session/session.js +2 -0
  165. package/src/jobs/check.ts +7 -4
  166. package/src/jobs/context.ts +24 -82
  167. package/src/jobs/permissions/decide-permission.ts +10 -8
  168. package/src/jobs/permissions/find-project-scope.ts +8 -14
  169. package/src/jobs/permissions/match-target.ts +3 -3
  170. package/src/jobs/permissions.ts +8 -6
  171. package/src/jobs/program.ts +13 -4
  172. package/src/jobs/prompt.ts +21 -24
  173. package/src/jobs/schedule.ts +14 -14
  174. package/src/jobs/slash-command.ts +20 -14
  175. package/src/jobs/status-line.ts +9 -21
  176. package/src/jobs/tool.ts +17 -11
  177. package/src/mod.ts +252 -97
  178. package/src/options.ts +22 -22
  179. package/src/records.ts +40 -23
  180. package/src/register.ts +34 -5
  181. package/src/runtime/announce.ts +76 -0
  182. package/src/runtime/before.ts +200 -0
  183. package/src/runtime/bound.ts +12 -0
  184. package/src/runtime/claude.ts +7 -2
  185. package/src/runtime/conversations.ts +579 -0
  186. package/src/runtime/deadline.ts +2 -2
  187. package/src/runtime/dependencies.ts +13 -1
  188. package/src/runtime/engine.ts +23 -0
  189. package/src/runtime/grants.ts +69 -29
  190. package/src/runtime/hooks.ts +39 -14
  191. package/src/runtime/installer.ts +16 -14
  192. package/src/runtime/lifecycle.ts +413 -201
  193. package/src/runtime/metadata.ts +37 -14
  194. package/src/runtime/options.ts +19 -4
  195. package/src/runtime/programs.ts +40 -1
  196. package/src/runtime/router.ts +11 -4
  197. package/src/runtime/sessions.ts +447 -0
  198. package/src/runtime/state.ts +50 -14
  199. package/src/runtime/toaster.ts +334 -0
  200. package/src/runtime/ui.ts +65 -143
  201. package/src/session/context/context.ts +14 -0
  202. package/src/session/context/turn/message/document.ts +8 -0
  203. package/src/session/context/turn/message/message.ts +203 -0
  204. package/src/session/context/turn/message/note.ts +9 -0
  205. package/src/session/context/turn/message/notice.ts +7 -0
  206. package/src/session/context/turn/message/prompt.ts +9 -0
  207. package/src/session/context/turn/message/reply.ts +7 -0
  208. package/src/session/context/turn/message/sender.ts +38 -0
  209. package/src/session/context/turn/message/skill.ts +8 -0
  210. package/src/session/context/turn/message/tool-call.ts +10 -0
  211. package/src/session/context/turn/message/tool-result.ts +9 -0
  212. package/src/session/context/turn/turn.ts +15 -0
  213. package/src/session/conversation.ts +194 -0
  214. package/src/session/goal/goal.ts +10 -0
  215. package/src/session/goal/task.ts +19 -0
  216. package/src/session/session.ts +141 -0
  217. package/src/testing/fake-claude.ts +25 -17
  218. package/src/testing/fake-elements.ts +3 -2
  219. package/src/testing/fake-files.ts +1 -1
  220. package/src/testing/fake-sessions.ts +277 -0
  221. package/src/testing.ts +138 -35
  222. package/src/ui/components.ts +3 -3
  223. package/src/ui/elements.ts +22 -8
  224. package/src/ui/markdown.ts +22 -26
  225. package/src/ui/places.ts +49 -0
  226. package/src/utils/call-effects.ts +5 -5
  227. package/src/utils/changelog.ts +32 -0
  228. package/src/utils/disposable.ts +3 -0
  229. package/src/utils/matcher.ts +37 -0
  230. package/src/utils/metadata.ts +17 -15
  231. package/src/utils/parse-shell.ts +9 -8
  232. package/src/utils/paths.ts +21 -4
  233. package/src/utils/text.ts +4 -0
  234. package/testing/fake-claude.d.ts +5 -1
  235. package/testing/fake-claude.d.ts.map +1 -1
  236. package/testing/fake-claude.js +21 -12
  237. package/testing/fake-elements.js +4 -2
  238. package/testing/fake-files.js +1 -1
  239. package/testing/fake-sessions.d.ts +81 -0
  240. package/testing/fake-sessions.d.ts.map +1 -0
  241. package/testing/fake-sessions.js +249 -0
  242. package/testing.d.ts +34 -13
  243. package/testing.d.ts.map +1 -1
  244. package/testing.js +115 -23
  245. package/ui/components.d.ts +2 -3
  246. package/ui/components.d.ts.map +1 -1
  247. package/ui/components.js +3 -3
  248. package/ui/elements.d.ts.map +1 -1
  249. package/ui/elements.js +19 -9
  250. package/ui/markdown.d.ts +35 -33
  251. package/ui/markdown.d.ts.map +1 -1
  252. package/ui/markdown.js +14 -16
  253. package/ui/places.d.ts +43 -0
  254. package/ui/places.d.ts.map +1 -0
  255. package/ui/places.js +31 -0
  256. package/utils/call-effects.d.ts +2 -1
  257. package/utils/call-effects.d.ts.map +1 -1
  258. package/utils/call-effects.js +3 -3
  259. package/utils/changelog.d.ts +9 -0
  260. package/utils/changelog.d.ts.map +1 -0
  261. package/utils/changelog.js +29 -0
  262. package/utils/disposable.d.ts +4 -0
  263. package/utils/disposable.d.ts.map +1 -0
  264. package/utils/disposable.js +1 -0
  265. package/utils/matcher.d.ts +13 -0
  266. package/utils/matcher.d.ts.map +1 -0
  267. package/utils/matcher.js +32 -0
  268. package/utils/metadata.d.ts +3 -3
  269. package/utils/metadata.js +18 -14
  270. package/utils/parse-shell.js +9 -7
  271. package/utils/paths.d.ts +4 -1
  272. package/utils/paths.d.ts.map +1 -1
  273. package/utils/paths.js +19 -4
  274. package/utils/text.d.ts +1 -0
  275. package/utils/text.d.ts.map +1 -1
  276. package/utils/text.js +3 -0
  277. package/src/ui/slots.ts +0 -36
  278. package/ui/slots.d.ts +0 -208
  279. package/ui/slots.d.ts.map +0 -1
  280. package/ui/slots.js +0 -19
package/src/testing.ts CHANGED
@@ -17,23 +17,39 @@ import type {
17
17
  UiPressArgument,
18
18
  UiSelectArgument,
19
19
  } from 'claude-code'
20
- import type { Reply } from './jobs/slash-command.js'
21
- import type { ModDefinition, ModEvent } from './mod.js'
20
+ import type { CommandReply } from './jobs/slash-command.js'
21
+ import type { Hook, ModDefinition } from './mod.js'
22
22
  import type { Options, OptionValues } from './options.js'
23
23
  import { readSteps, storeFolder, type Steps } from './records.js'
24
24
  import type { Claude } from './runtime/claude.js'
25
- import { answerCall, notInstalled } from './runtime/dependencies.js'
25
+ import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
26
+ import { foldAnswers, installBefore } from './runtime/before.js'
26
27
  import type { RoutedEvent } from './runtime/hooks.js'
28
+ import { createRouter } from './runtime/router.js'
27
29
  import { toolInputOf } from './runtime/tool-calls.js'
28
30
  import { createLifecycle } from './runtime/lifecycle.js'
29
31
  import { MissingOptions } from './runtime/options.js'
30
- import { fakeClaude, type Fakes, type Shown, type TestCall } from './testing/fake-claude.js'
32
+ import { conversationMethods } from './runtime/sessions.js'
33
+ import type { ShownToast } from './runtime/toaster.js'
34
+ import type { Message } from './session/context/turn/message/message.js'
35
+ import { Note } from './session/context/turn/message/note.js'
36
+ import { Prompt } from './session/context/turn/message/prompt.js'
37
+ import { Mod, Person } from './session/context/turn/message/sender.js'
38
+ import type { SessionChange } from './session/conversation.js'
39
+ import type { BeforeEvent } from './session/session.js'
40
+ import { calledAnswer, fakeClaude, type Fakes, type Shown as ShownByClaude, type TestCall } from './testing/fake-claude.js'
31
41
  import { elements, findElement, rowsOf } from './testing/fake-elements.js'
32
42
  import { fakeFiles } from './testing/fake-files.js'
33
- import { listed } from './utils/text.js'
34
- import type { Slot, SlotProps } from './ui/slots.js'
43
+ import { fakeSessions, type FakeSession, type FakeSessions, type MessageDraft } from './testing/fake-sessions.js'
44
+ import { listed, messageOf } from './utils/text.js'
45
+ import { componentOf, type ComponentPlace, type PlaceProps } from './ui/places.js'
35
46
 
36
- export type { Fakes, Shown, TestCall } from './testing/fake-claude.js'
47
+ export type { Fakes, TestCall } from './testing/fake-claude.js'
48
+ export type { FakeSession, MessageDraft } from './testing/fake-sessions.js'
49
+ export type { RoutedEvent } from './runtime/hooks.js'
50
+ export type { ShownToast } from './runtime/toaster.js'
51
+
52
+ export type Shown = ShownByClaude & { readonly toasts: readonly ShownToast[]; readonly sent: FakeSessions['sent'] }
37
53
 
38
54
  export type TestOptions<State extends object, Declared extends Options = Options> = {
39
55
  readonly state?: { readonly [Lifetime in keyof State]?: Partial<State[Lifetime]> }
@@ -45,23 +61,28 @@ export type TestOptions<State extends object, Declared extends Options = Options
45
61
  readonly dependencies?: { readonly [Name in keyof CmodDependencies]?: CmodDependencies[Name] }
46
62
  readonly files?: Readonly<Record<string, string>>
47
63
  readonly links?: Readonly<Record<string, string>>
64
+ readonly sessions?: readonly FakeSession[]
48
65
  }
49
66
 
50
67
  type FilledField = 'session_id' | 'transcript_path' | 'cwd' | 'hook_event_name' | 'tool_use_id'
51
68
 
52
- export type TestInput<E extends ModEvent> = Omit<ClassicHookInputs[E], FilledField> & Partial<Pick<ClassicHookInputs[E], FilledField & keyof ClassicHookInputs[E]>>
69
+ export type TestInput<H extends Hook> = Omit<ClassicHookInputs[H], FilledField> & Partial<Pick<ClassicHookInputs[H], FilledField & keyof ClassicHookInputs[H]>>
53
70
 
54
71
  export type TestedMod<State extends object> = {
55
72
  start(): Promise<void>
56
- fire<E extends ModEvent>(event: E, input: TestInput<E>, below?: EventResult<`classic.${E}`>): Promise<EventResult<`classic.${E}`>>
73
+ fire<H extends Hook>(hook: H, input: TestInput<H>, below?: EventResult<`classic.${H}`>): Promise<EventResult<`classic.${H}`>>
57
74
  fire<N extends RoutedEvent>(event: N, input: Args<N>, below?: EventResult<N>): Promise<EventResult<N>>
58
75
  settle(): Promise<void>
76
+ add(message: MessageDraft, options?: { readonly task?: string }): Promise<Message[]>
77
+ endTurn(): Promise<void>
78
+ compact(): Promise<void>
79
+ startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string }): Promise<string>
59
80
  lines(paneId: string): Promise<string[]>
60
- lines<S extends Slot<object, object, { readonly component: RenderComponent }>>(slot: S, props: Omit<SlotProps<S>, 'Default'>, requestId?: string): Promise<string[]>
61
- type(line: string): Promise<Reply>
81
+ lines<P extends ComponentPlace>(place: P, props: Omit<PlaceProps<P>, 'Default'>, requestId?: string): Promise<string[]>
82
+ type(line: string): Promise<CommandReply>
62
83
  callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
63
84
  press(paneId: string, key: string, link?: string): Promise<void>
64
- input(paneId: string, key: string, text: string, kind?: UiInputArgument['kind']): Promise<void>
85
+ input(paneId: string, key: string, text: string, type?: UiInputArgument['kind']): Promise<void>
65
86
  select(paneId: string, key: string, value: string): Promise<void>
66
87
  moveTo(projectRoot: string, cwd?: string): Promise<void>
67
88
  readonly state: Readonly<State>
@@ -76,7 +97,7 @@ const defaultRows = 24
76
97
 
77
98
  const presentation: CommandPresentation = { isFullscreen: false, columns: defaultColumns }
78
99
 
79
- const toolUseEvents: readonly string[] = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'] satisfies readonly ModEvent[]
100
+ const toolUseEvents: readonly string[] = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'] satisfies readonly Hook[]
80
101
 
81
102
  export function testMod<State extends object, Declared extends Options = Options>(definition: ModDefinition<State, string, Declared>, options: TestOptions<State, Declared> = {}): TestedMod<State> {
82
103
  installJsx()
@@ -93,14 +114,38 @@ export function testMod<State extends object, Declared extends Options = Options
93
114
  fake.claude.session.id = async () => sessionId
94
115
  fake.claude.session.root = async () => projectRoot
95
116
  fake.claude.session.cwd = async () => cwd
96
- fake.fakes.cmod.call = fakeDependencies(options.dependencies ?? {})
117
+ const conversations = fakeSessions({
118
+ callTool: (args: Parameters<Claude["tool"]["call"]>[0]) => fake.claude.tool.call(args),
119
+ project: () => projectRoot,
120
+ given: options.sessions ?? [],
121
+ push: (mod, session, change) => {
122
+ if (mod !== name) return
123
+ const pushed = { to: mod, method: conversationMethods.changed, input: { session, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
124
+ void lifecycle.route('cmod.call', pushed, async () => ({ deny: notInstalled(mod) }))
125
+ },
126
+ })
127
+ conversations.start(sessionId)
128
+ fake.fakes.cmod.call = fakeDependencies(name, options.dependencies ?? {}, conversations)
129
+ const appendRow = fake.claude.session.append
130
+ fake.claude.session.append = async (args) => {
131
+ const added = await appendRow(args)
132
+ if (added.deny === undefined) conversations.add({ type: Note, text: args.message.content.map((block) => (typeof block['text'] === 'string' ? block['text'] : '')).join(''), from: { type: Mod, name } })
133
+ return added
134
+ }
135
+ const submitRow = fake.claude.prompt.submit
136
+ fake.claude.prompt.submit = async (args) => {
137
+ const submitted = await submitRow(args)
138
+ conversations.add({ type: Prompt, text: args.text, from: { type: Mod, name, ...(args.asUser === true ? { as: Person } : {}) } })
139
+ return submitted
140
+ }
97
141
  const firedAgents = new Map<string, string>()
98
142
  const listAgents = fake.claude.agent.list
99
143
  fake.claude.agent.list = async () => {
100
144
  if (fake.fakes.agent.list !== undefined || firedAgents.size === 0) return listAgents()
101
145
  return [...firedAgents].map(([id, type]) => ({ id, type, description: '', status: 'running' as const }))
102
146
  }
103
- Object.assign(fake.fakes.fs, fakeFiles(options.files ?? {}, options.links))
147
+ const files = fakeFiles(options.files ?? {}, options.links)
148
+ Object.assign(fake.fakes.fs, files)
104
149
  const starting = options.state as Record<string, object> | undefined
105
150
  const declared = Object.entries(definition.state ?? {}) as [string, object][]
106
151
  const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }])) as State
@@ -122,6 +167,7 @@ export function testMod<State extends object, Declared extends Options = Options
122
167
  }
123
168
  })
124
169
  await started
170
+ await fake.settle()
125
171
  const { failure } = lifecycle
126
172
  if (failure instanceof MissingOptions) {
127
173
  const given = failure.keys.map((key) => `${key}: …`).join(', ')
@@ -130,9 +176,24 @@ export function testMod<State extends object, Declared extends Options = Options
130
176
  if (lifecycle.phase !== 'active') throw failure ?? new Error(`${name} did not start: the lifecycle is ${lifecycle.phase}.`)
131
177
  }
132
178
 
179
+ const cmodPlugin = createRouter()
180
+ const askMod = async (event: BeforeEvent, change: SessionChange) => {
181
+ const asked = { to: name, method: conversationMethods.before, input: { session: sessionId, event, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
182
+ return calledAnswer(cmodPluginName, await lifecycle.route('cmod.call', asked, async () => ({ deny: notInstalled(name) })))
183
+ }
184
+ installBefore({
185
+ claude: fake.claude,
186
+ router: cmodPlugin,
187
+ hooks: {
188
+ place: async () => conversations.place(),
189
+ wants: async (event) => conversations.wants(name, event),
190
+ decide: (event, change) => foldAnswers(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
191
+ },
192
+ })
193
+
133
194
  const route = async <N extends RoutedEvent>(event: N, input: unknown, below: unknown): Promise<EventResult<N>> => {
134
195
  await start()
135
- return lifecycle.route(event, input as Frozen<Args<N>>, async (e) => (below ?? claudeCodeAnswer(event, e)) as EventResult<N>)
196
+ return cmodPlugin.dispatch(event, input as Frozen<Args<N>>, (passed) => lifecycle.route(event, passed, async (e) => (below ?? claudeCodeAnswer(event, e)) as EventResult<N>))
136
197
  }
137
198
 
138
199
  const drawPane = async (paneId: string) => {
@@ -166,14 +227,19 @@ export function testMod<State extends object, Declared extends Options = Options
166
227
 
167
228
  const fire = ((event: string, input: object, below?: unknown) => {
168
229
  if (event.includes('.')) return route(event as RoutedEvent, input, below)
169
- if (event === 'SessionStart') sessionId = (input as { session_id?: string }).session_id ?? sessionId
230
+ if (event === 'SessionStart') {
231
+ sessionId = (input as { session_id?: string }).session_id ?? sessionId
232
+ conversations.start(sessionId)
233
+ }
170
234
  if (event === 'UserPromptSubmit') {
171
235
  cwd = cwdAfterPrompt ?? cwd
172
236
  cwdAfterPrompt = undefined
173
237
  }
174
238
  const toolUse = toolUseEvents.includes(event) ? { tool_use_id: `toolu_${(toolUses += 1)}` } : {}
175
- const filled = { session_id: sessionId, transcript_path: '/test/transcript.jsonl', cwd, hook_event_name: event, ...toolUse, ...input }
176
- if (event !== 'PreToolUse') return route(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, filled, below)
239
+ const transcript = `/test/transcripts/${sessionId}.jsonl`
240
+ const filled = { session_id: sessionId, transcript_path: transcript, cwd, hook_event_name: event, ...toolUse, ...input }
241
+ if (event === 'SessionStart' && !('transcript_path' in input)) return files.write(transcript, '').then(() => route('classic.SessionStart', filled, below))
242
+ if (event !== 'PreToolUse') return route(`classic.${event as Exclude<Hook, 'PreToolUse'>}`, filled, below)
177
243
  return firePreToolUse(filled as ClassicHookInputs['PreToolUse'], below as { deny?: string } | undefined)
178
244
  }) as TestedMod<State>['fire']
179
245
 
@@ -187,7 +253,7 @@ export function testMod<State extends object, Declared extends Options = Options
187
253
  const answer = await lifecycle.route('tool.call', envelope, async (e) => {
188
254
  reached = e
189
255
  check = await lifecycle.route('tool.check', { tool: e.tool, input: toolInputOf(e), tool_use_id: e.tool_use_id, ...(e.agentId === undefined ? {} : { agentId: e.agentId }) }, async () => asks)
190
- return (check.decision === 'deny' ? { deny: check.reason ?? '' } : { result: '' }) as EventResult<'tool.call'>
256
+ return check.decision === 'deny' ? { deny: check.reason ?? '' } : { result: '' }
191
257
  })
192
258
  const updatedInput = toolInputOf(reached)
193
259
  const decided = check === asks ? {} : check.decision === 'allow' ? { allow: true } : check.decision === 'ask' ? { ask: check.reason ?? '' } : {}
@@ -198,14 +264,37 @@ export function testMod<State extends object, Declared extends Options = Options
198
264
  } as EventResult<'classic.PreToolUse'>
199
265
  }
200
266
 
267
+ const settled = async <Value>(value: Value) => {
268
+ await fake.settle()
269
+ return value
270
+ }
271
+
201
272
  return {
202
273
  start,
203
274
  fire,
204
275
  settle: fake.settle,
205
- lines: (async (target: string | { readonly component: RenderComponent }, props: object = {}, requestId?: string) => {
206
- const { drawing, columns } = typeof target === 'string' ? await drawPane(target) : await drawSlot(target.component, props, requestId)
276
+ add: async (message, added) => {
277
+ await start()
278
+ return settled(conversations.add(message, added?.task))
279
+ },
280
+ endTurn: async () => {
281
+ await start()
282
+ await settled(conversations.endTurn())
283
+ },
284
+ compact: async () => {
285
+ await start()
286
+ await settled(conversations.compact())
287
+ },
288
+ startTask: async (task) => {
289
+ await start()
290
+ return settled(conversations.startTask(task))
291
+ },
292
+ lines: async (target: string, props?: object, requestId?: string) => {
293
+ const component = componentOf(target as ComponentPlace)
294
+ if (props !== undefined && component === undefined) throw new Error(`lines takes a pane id, or a place render draws and its props, and ${target} is neither a place such as ToolCall nor called without props.`)
295
+ const { drawing, columns } = props === undefined || component === undefined ? await drawPane(target) : await drawSlot(component, props, requestId)
207
296
  return rowsOf(drawing, columns).map((row) => row.trimEnd())
208
- }) as TestedMod<State>['lines'],
297
+ },
209
298
  async type(line) {
210
299
  const typed = /^\/(\S+)\s*(.*)$/s.exec(line)
211
300
  if (typed === null) throw new Error(`type takes a slash command the way the user types it, such as tested.type('/help'), and "${line}" does not start with /.`)
@@ -230,16 +319,16 @@ export function testMod<State extends object, Declared extends Options = Options
230
319
  const props = await drawnElement(paneId, link === undefined ? 'Button' : 'Markdown', key)
231
320
  if (link !== undefined && props['onLinkPress'] === undefined) throw new Error(`The Markdown "${key}" in the pane "${paneId}" of ${name} has no onLinkPress, so Claude Code opens its links itself.`)
232
321
  const pressed: UiPressArgument = { ...eventIn(paneId, String(props['key'])), ...(link === undefined ? {} : { link: { href: link } }) }
233
- await lifecycle.route('ui.press', pressed as Frozen<Args<'ui.press'>>, async (e) => {
322
+ await lifecycle.route('ui.press', pressed, async (e) => {
234
323
  if (e.link === undefined) await (props['onPress'] as (e: UiPressArgument) => unknown)(e)
235
324
  else await (props['onLinkPress'] as (link: PressedLink, e: UiPressArgument) => unknown)(e.link, e)
236
325
  return { element: e.element }
237
326
  })
238
327
  },
239
- async input(paneId, key, text, kind = 'submit') {
328
+ async input(paneId, key, text, type = 'submit') {
240
329
  const props = await drawnElement(paneId, 'Input', key)
241
- const typed: UiInputArgument = { ...eventIn(paneId, key), kind, value: text }
242
- await (props[kind === 'submit' ? 'onSubmit' : 'onInput'] as ((value: string, e: UiInputArgument) => unknown) | undefined)?.(text, typed)
330
+ const typed: UiInputArgument = { ...eventIn(paneId, key), kind: type, value: text }
331
+ await (props[type === 'submit' ? 'onSubmit' : 'onInput'] as ((value: string, e: UiInputArgument) => unknown) | undefined)?.(text, typed)
243
332
  },
244
333
  async select(paneId, key, value) {
245
334
  const props = await drawnElement(paneId, 'Select', key)
@@ -269,21 +358,32 @@ export function testMod<State extends object, Declared extends Options = Options
269
358
  },
270
359
  calls: fake.calls,
271
360
  fakes: fake.fakes,
272
- shown: fake.shown,
361
+ shown: {
362
+ ...fake.shown,
363
+ get toasts() {
364
+ return lifecycle.toasts
365
+ },
366
+ sent: conversations.sent,
367
+ },
273
368
  }
274
369
  }
275
370
 
276
- function fakeDependencies(dependencies: NonNullable<TestOptions<object>['dependencies']>): Claude['cmod']['call'] {
277
- const apis = dependencies as Readonly<Record<string, Parameters<typeof answerCall>[1] | undefined>>
371
+ function fakeDependencies(caller: string, dependencies: NonNullable<TestOptions<object>['dependencies']>, conversations: FakeSessions): Claude['cmod']['call'] {
372
+ const cmod = { forkParent: () => null, updateSettings: () => null }
373
+ const apis = { [cmodPluginName]: cmod, ...dependencies } as Readonly<Record<string, Parameters<typeof answerCall>[1] | undefined>>
278
374
  return async (call) => {
375
+ const served = call.to === cmodPluginName ? conversations.answer(caller, call.method, call.input) : undefined
376
+ if (served !== undefined) return calledAnswer(caller, served as EventResult<'cmod.call'>)
279
377
  const api = apis[call.to]
280
- const answer = api === undefined ? { deny: notInstalled(call.to) } : await answerCall(call.to, api, call)
281
- if (answer.deny !== undefined) throw new Error(answer.deny)
282
- return answer.value
378
+ return calledAnswer(caller, api === undefined ? { deny: notInstalled(call.to) } : await answerCall(call.to, api, call))
283
379
  }
284
380
  }
285
381
 
286
382
  function claudeCodeAnswer(event: RoutedEvent, e: unknown): unknown {
383
+ if (event === 'agent.spawn') {
384
+ const { model, parentModel } = e as Args<'agent.spawn'>
385
+ return { agentId: 'agent-1', model: model ?? parentModel }
386
+ }
287
387
  if (event !== 'prompt.submit') return {}
288
388
  const { text, context, origin } = e as Args<'prompt.submit'>
289
389
  return { text, ...(context === undefined ? {} : { context }), origin }
@@ -294,7 +394,7 @@ function plainOutput({ component, props }: Args<'ui.render'>): RenderElement {
294
394
  return elements.Box({ flexDirection: 'column', children: [elements.Text({ children: component }), elements.Box({ flexDirection: 'column', paddingLeft: 2, children: rows })] })
295
395
  }
296
396
 
297
- function replyOf({ text, context }: CommandRunResult): Reply {
397
+ function replyOf({ text, context }: CommandRunResult): CommandReply {
298
398
  return { ...(text === undefined ? {} : { text }), ...(context === undefined ? {} : { context: context.join('\n') }) }
299
399
  }
300
400
 
@@ -302,7 +402,10 @@ async function modSteps(): Promise<Steps> {
302
402
  const manifest = '../../../package.json'
303
403
  const loaded: unknown = await import(manifest, { with: { type: 'json' } }).then(
304
404
  (module: { default: unknown }) => module.default,
305
- () => undefined,
405
+ (error: { readonly code?: unknown }) => {
406
+ if (error.code === 'ERR_MODULE_NOT_FOUND') return undefined
407
+ throw error
408
+ },
306
409
  )
307
410
  return readSteps(loaded) ?? {}
308
411
  }
@@ -49,9 +49,9 @@ export function Pagination({ page, pages, onPage }: { readonly page: number; rea
49
49
  })
50
50
  }
51
51
 
52
- export function ProgressBar({ done, total, width = 24 }: { readonly done: number; readonly total: number; readonly width?: number }): RenderElement {
53
- const filled = total > 0 ? Math.round(Math.min(done / total, 1) * width) : 0
54
- return Text({ children: [Text({ color: 'claude', children: '█'.repeat(filled) }), Text({ color: 'subtle', children: '░'.repeat(width - filled) }), ' ', Text({ dimColor: true, children: `${done}/${total}` })] })
52
+ export function Progress({ value, width = 24 }: { readonly value: number; readonly width?: number }): RenderElement {
53
+ const filled = Math.round((Math.min(Math.max(value, 0), 100) / 100) * width)
54
+ return Text({ children: [Text({ color: 'claude', children: '█'.repeat(filled) }), Text({ color: 'subtle', children: '░'.repeat(width - filled) })] })
55
55
  }
56
56
 
57
57
  const cardColumns = 36
@@ -6,25 +6,39 @@ type CompletedTable = Record<ElementName, (props: object) => RenderElement>
6
6
 
7
7
  type Drawing = { readonly table: CompletedTable; readonly markdown: ((props: MarkdownProps) => RenderElement) | undefined }
8
8
 
9
+ const unbuilt = Symbol('an element built outside a render')
10
+
11
+ type Unbuilt = { readonly [unbuilt]: { readonly name: ElementName; readonly props: object } }
12
+
9
13
  let drawing: Drawing | undefined
10
14
 
11
15
  export function drawWith<Drawn>(table: ElementTable, draw: () => Drawn, markdown?: (props: MarkdownProps) => RenderElement): Drawn {
12
16
  const outer = drawing
13
- drawing = { table: table as CompletedTable, markdown }
17
+ const current = { table: table as CompletedTable, markdown }
18
+ drawing = current
14
19
  try {
15
- return draw()
20
+ return built(current, draw()) as Drawn
16
21
  } finally {
17
22
  drawing = outer
18
23
  }
19
24
  }
20
25
 
21
26
  function element<Name extends keyof Terminal & ElementName>(name: Name): Terminal[Name] {
22
- const construct = (props: object) => {
23
- if (drawing === undefined) throw new Error(`${name} was called outside a render. Use it inside a pane's render or a slot's component.`)
24
- if (name === 'Markdown' && drawing.markdown !== undefined) return drawing.markdown(props as MarkdownProps)
25
- return drawing.table[name](props)
26
- }
27
- return construct as Terminal[Name]
27
+ const construct = (props: object) => (drawing === undefined ? ({ [unbuilt]: { name, props } } satisfies Unbuilt as unknown as RenderElement) : build(drawing, name, props))
28
+ return construct
29
+ }
30
+
31
+ function build(current: Drawing, name: ElementName, props: object): RenderElement {
32
+ const ready = 'children' in props ? { ...props, children: built(current, props.children) } : props
33
+ if (name === 'Markdown' && current.markdown !== undefined) return current.markdown(ready as MarkdownProps)
34
+ return current.table[name](ready)
35
+ }
36
+
37
+ function built(current: Drawing, value: unknown): unknown {
38
+ if (Array.isArray(value)) return value.map((child: unknown) => built(current, child))
39
+ if (typeof value !== 'object' || value === null || !(unbuilt in value)) return value
40
+ const { name, props } = (value as Unbuilt)[unbuilt]
41
+ return build(current, name, props)
28
42
  }
29
43
 
30
44
  export const Box = element('Box')
@@ -1,11 +1,9 @@
1
1
  import type { Nodes, RootContent } from 'mdast'
2
2
  import { fromMarkdown, gfm, gfmFromMarkdown, toString } from '../vendor-markdown.js'
3
- import type { Slot, SlotProps } from './slots.js'
3
+ import type { PlaceProps } from './places.js'
4
4
 
5
5
  type Block = { readonly text: string; readonly source: string }
6
6
 
7
- type MarkdownSlot<Props extends object = object> = Slot<Props, { readonly source?: string }, { readonly markdown: MarkdownKind }>
8
-
9
7
  type BlockFields = {
10
8
  heading: { readonly depth: 1 | 2 | 3 | 4 | 5 | 6 }
11
9
  code: { readonly lang: string | undefined; readonly meta: string | undefined; readonly value: string }
@@ -14,18 +12,31 @@ type BlockFields = {
14
12
  html: { readonly value: string }
15
13
  }
16
14
 
17
- type BlockProps<Type extends string> = Type extends keyof BlockFields ? Block & BlockFields[Type] : Block
15
+ export type BlockProps<Type extends string> = Type extends keyof BlockFields ? Block & BlockFields[Type] : Block
18
16
 
19
17
  export type MarkdownPiece<Render> = { readonly text: string } | { readonly render: Render; readonly props: Block }
20
18
 
21
19
  export type MarkdownReader = <Render>(text: string, renderOf: (type: string) => Render | undefined, replyId?: string) => readonly MarkdownPiece<Render>[]
22
20
 
23
- export type MarkdownKind = {
21
+ export type MarkdownPlace<Type extends string = string> = {
24
22
  readonly name: string
25
- readonly type: string
26
- readonly createReader: () => MarkdownReader
23
+ readonly markdown: Type
24
+ createReader(): MarkdownReader
27
25
  }
28
26
 
27
+ function markdownPlace<const Type extends string>(name: string, markdown: Type): MarkdownPlace<Type> {
28
+ return { name, markdown, createReader }
29
+ }
30
+
31
+ export const Heading = markdownPlace('Heading', 'heading')
32
+ export const Paragraph = markdownPlace('Paragraph', 'paragraph')
33
+ export const CodeBlock = markdownPlace('CodeBlock', 'code')
34
+ export const BlockQuote = markdownPlace('BlockQuote', 'blockquote')
35
+ export const List = markdownPlace('List', 'list')
36
+ export const Table = markdownPlace('Table', 'table')
37
+ export const ThematicBreak = markdownPlace('ThematicBreak', 'thematicBreak')
38
+ export const HtmlBlock = markdownPlace('HtmlBlock', 'html')
39
+
29
40
  type Parsed = {
30
41
  readonly type: string
31
42
  readonly start: number
@@ -52,7 +63,7 @@ const blankLine = /\n[^\S\n]*\n/
52
63
 
53
64
  const fence = /^ {0,3}(`{3}|~{3})/
54
65
 
55
- function createReader(): MarkdownReader {
66
+ export function createReader(): MarkdownReader {
56
67
  const replies: Reply[] = []
57
68
 
58
69
  const remember = (reply: Reply) => {
@@ -152,22 +163,7 @@ function plainText(node: Nodes): string {
152
163
  return node.children.map(plainText).join(node.type === 'tableRow' ? '\t' : '\n')
153
164
  }
154
165
 
155
- function markdownSlot<Type extends RootContent['type']>(name: string, type: Type): MarkdownSlot<BlockProps<Type>> {
156
- return { markdown: { name, type, createReader } } as MarkdownSlot<BlockProps<Type>>
157
- }
158
-
159
- export const markdownSlots = {
160
- Heading: markdownSlot('Heading', 'heading'),
161
- Paragraph: markdownSlot('Paragraph', 'paragraph'),
162
- CodeBlock: markdownSlot('CodeBlock', 'code'),
163
- BlockQuote: markdownSlot('BlockQuote', 'blockquote'),
164
- List: markdownSlot('List', 'list'),
165
- Table: markdownSlot('Table', 'table'),
166
- ThematicBreak: markdownSlot('ThematicBreak', 'thematicBreak'),
167
- HtmlBlock: markdownSlot('HtmlBlock', 'html'),
168
- }
169
-
170
- export function markdownBlocks<S extends MarkdownSlot>(text: string, slot: S): Omit<SlotProps<S>, 'Default'>[] {
171
- const blocks = parsed(text, 0).filter((block) => block.type === slot.markdown.type)
172
- return blocks.map((block) => block.props) as unknown as Omit<SlotProps<S>, 'Default'>[]
166
+ export function markdownBlocks<P extends MarkdownPlace>(text: string, place: P): Omit<PlaceProps<P>, 'Default'>[] {
167
+ const blocks = parsed(text, 0).filter((block) => block.type === place.markdown)
168
+ return blocks.map((block) => block.props) as unknown as Omit<PlaceProps<P>, 'Default'>[]
173
169
  }
@@ -0,0 +1,49 @@
1
+ import type { Frozen, RenderComponent, RenderElement, RenderPropsOf } from 'claude-code'
2
+ import type { BlockProps, MarkdownPlace } from './markdown.js'
3
+
4
+ export const ToolGroup = 'toolGroup'
5
+ export const ToolProgress = 'toolProgress'
6
+ export const CommandOutput = 'commandOutput'
7
+ export const AskUserQuestion = 'askUserQuestion'
8
+ export const Spinner = 'spinner'
9
+ export const TurnDuration = 'turnDuration'
10
+ export const SessionMode = 'sessionMode'
11
+ export const PromptHint = 'promptHint'
12
+ export const AbovePrompt = 'abovePrompt'
13
+
14
+ const components = {
15
+ prompt: 'UserMessage',
16
+ reply: 'AssistantMessage',
17
+ toolCall: 'ToolUse',
18
+ toolResult: 'ToolResult',
19
+ notice: 'InfoNotice',
20
+ [ToolGroup]: 'ToolGroup',
21
+ [ToolProgress]: 'ToolProgress',
22
+ [CommandOutput]: 'CommandOutput',
23
+ [AskUserQuestion]: 'AskUserQuestion',
24
+ [Spinner]: 'Spinner',
25
+ [TurnDuration]: 'TurnDuration',
26
+ [SessionMode]: 'SessionMode',
27
+ [PromptHint]: 'PromptHint',
28
+ [AbovePrompt]: 'AbovePrompt',
29
+ } as const satisfies Readonly<Record<string, Exclude<RenderComponent, 'Pane'>>>
30
+
31
+ export type ComponentPlace = keyof typeof components
32
+
33
+ export type Place = ComponentPlace | MarkdownPlace
34
+
35
+ type ComponentProps = { readonly [P in ComponentPlace]: RenderPropsOf[(typeof components)[P]] }
36
+
37
+ type PropsOf<P extends Place> = P extends ComponentPlace ? ComponentProps[P] : P extends MarkdownPlace<infer Type> ? BlockProps<Type> : never
38
+
39
+ type DefaultOf<P extends Place> = P extends ComponentPlace ? Partial<Omit<ComponentProps[P], 'onScreen'>> : { readonly source?: string }
40
+
41
+ export type PlaceProps<P extends Place> = Frozen<PropsOf<P>> & { readonly Default: (props: Frozen<DefaultOf<P>>) => RenderElement }
42
+
43
+ export function componentOf(place: Place): Exclude<RenderComponent, 'Pane'> | undefined {
44
+ return typeof place === 'string' && Object.hasOwn(components, place) ? components[place] : undefined
45
+ }
46
+
47
+ export function nameOf(place: Place): string {
48
+ return typeof place === 'string' ? `${place.charAt(0).toUpperCase()}${place.slice(1)}` : place.name
49
+ }
@@ -1,12 +1,12 @@
1
1
  import { parseShell, type ShellCommand } from './parse-shell.js'
2
- import { expandHome, type FileSystem } from './paths.js'
2
+ import { expandHome, ifMissing, type FileSystem } from './paths.js'
3
3
  import { resolve } from '../vendor.js'
4
4
 
5
5
  export type CallBase = { tool: string; input: Readonly<Record<string, unknown>>; agentId?: string; agentType?: string }
6
6
  export type CommandCall = CallBase & { commands: [string, ...string[]][]; isFullyParsed: boolean }
7
7
  export type FileCall = CallBase & { path: string; content?: string; previousContent?: string }
8
8
  export type FetchCall = CallBase & { url: string }
9
- export type ToolCall = CommandCall | FileCall | FetchCall | CallBase
9
+ export type Call = CommandCall | FileCall | FetchCall | CallBase
10
10
 
11
11
  export type Contents = { content?: string; previousContent?: string }
12
12
  export type FileAccess = { path: string; contents: (() => Promise<Contents>) | undefined }
@@ -18,9 +18,9 @@ export type CallEffects = {
18
18
  subagent: string | undefined
19
19
  }
20
20
 
21
- export function callEffects(use: CallBase, workspace: { cwd: string; home: string; fs: FileSystem }): CallEffects {
21
+ export function callEffects(use: CallBase, workspace: { cwd: string; home: string; configRoot?: string; fs: FileSystem }): CallEffects {
22
22
  const { fs } = workspace
23
- const absolute = (path: string, folder = workspace.cwd) => resolve(folder, expandHome(path, workspace.home))
23
+ const absolute = (path: string, folder = workspace.cwd) => resolve(folder, expandHome(path, workspace.home, workspace.configRoot))
24
24
  const access = (path: string): FileAccess => ({ path: absolute(path), contents: undefined })
25
25
  const effects: CallEffects = { shell: undefined, reads: [], writes: [], urls: [], subagent: undefined }
26
26
  if (use.tool === 'Bash') {
@@ -79,7 +79,7 @@ async function editedContents(use: CallBase, path: string, fs: FileSystem): Prom
79
79
  }
80
80
 
81
81
  async function previousContentOf(path: string, fs: FileSystem): Promise<string | undefined> {
82
- const stat = await fs.stat(path).catch(() => undefined)
82
+ const stat = await fs.stat(path).catch(ifMissing(undefined))
83
83
  return stat?.kind === 'file' ? fs.read(path) : undefined
84
84
  }
85
85
 
@@ -0,0 +1,32 @@
1
+ export type ChangelogSection = { readonly version: string; readonly date?: string; readonly text: string }
2
+
3
+ export const unreleased = 'Unreleased'
4
+
5
+ const heading = /^##\s+\[?([^\]\s]+)\]?(?:\s+-\s+(\S+))?\s*$/
6
+
7
+ const linkReference = /^\[[^\]]+\]:\s/
8
+
9
+ export function changelogSections(markdown: string): ChangelogSection[] {
10
+ const sections: { readonly version: string; readonly date: string | undefined; readonly lines: string[] }[] = []
11
+ for (const line of markdown.split('\n')) {
12
+ const match = heading.exec(line)
13
+ if (match !== null) sections.push({ version: match[1] as string, date: match[2], lines: [] })
14
+ else if (!linkReference.test(line)) sections.at(-1)?.lines.push(line)
15
+ }
16
+ return sections.map(({ version, date, lines }) => ({ version, ...(date === undefined ? {} : { date }), text: lines.join('\n').trim() }))
17
+ }
18
+
19
+ export function changeLines(text: string): string[] {
20
+ let group: string | undefined
21
+ return text.split('\n').flatMap((line) => {
22
+ const trimmed = line.trim()
23
+ const subheading = /^###\s+(.+)$/.exec(trimmed)?.[1]
24
+ if (subheading !== undefined) {
25
+ group = subheading
26
+ return []
27
+ }
28
+ const item = /^[-*]\s+(.+)$/.exec(trimmed)?.[1] ?? trimmed
29
+ if (item === '') return []
30
+ return [group === undefined ? item : `${group}: ${item}`]
31
+ })
32
+ }
@@ -0,0 +1,3 @@
1
+ export type Disposable = {
2
+ dispose(): void
3
+ }
@@ -0,0 +1,37 @@
1
+ export type Matcher<Value> = Value extends string
2
+ ? string | RegExp
3
+ : Value extends number | boolean | null | undefined
4
+ ? Value
5
+ : Value extends readonly (infer Item)[]
6
+ ? Matcher<Item>
7
+ : Value extends (...args: never[]) => unknown
8
+ ? never
9
+ : Value extends object
10
+ ? { readonly [Key in keyof Value]?: Matcher<Value[Key]> }
11
+ : Value
12
+
13
+ export type SentMatcher = string | number | boolean | null | { readonly regexp: string; readonly flags: string } | { readonly [key: string]: SentMatcher } | readonly SentMatcher[]
14
+
15
+ export function matches(value: unknown, matcher: unknown): boolean {
16
+ if (matcher instanceof RegExp) return value !== undefined && value !== null && matcher.test(String(value))
17
+ if (Array.isArray(matcher)) return matcher.some((each) => matches(value, each))
18
+ if (typeof matcher !== 'object' || matcher === null) return value === matcher
19
+ if (Array.isArray(value)) return value.some((each) => matches(each, matcher))
20
+ if (typeof value !== 'object' || value === null) return false
21
+ return Object.entries(matcher).every(([key, wanted]) => wanted === undefined || matches((value as Record<string, unknown>)[key], wanted))
22
+ }
23
+
24
+ export function sentMatcher(matcher: unknown): SentMatcher {
25
+ if (matcher instanceof RegExp) return { regexp: matcher.source, flags: matcher.flags }
26
+ if (Array.isArray(matcher)) return matcher.map(sentMatcher)
27
+ if (typeof matcher !== 'object' || matcher === null) return matcher as SentMatcher
28
+ return Object.fromEntries(Object.entries(matcher).filter(([, value]) => value !== undefined).map(([key, value]) => [key, sentMatcher(value)]))
29
+ }
30
+
31
+ export function receivedMatcher(sent: SentMatcher): unknown {
32
+ if (Array.isArray(sent)) return sent.map(receivedMatcher)
33
+ if (typeof sent !== 'object' || sent === null) return sent
34
+ const fields = sent as Readonly<Record<string, unknown>>
35
+ if (typeof fields['regexp'] === 'string' && typeof fields['flags'] === 'string' && Object.keys(fields).length === 2) return new RegExp(fields['regexp'], fields['flags'])
36
+ return Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, receivedMatcher(value as SentMatcher)]))
37
+ }