@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
@@ -1,36 +1,41 @@
1
- import { consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn } from '../records.js';
2
- import { relativePath } from '../utils/paths.js';
3
- import { formatExit, listed, messageOf } from '../utils/text.js';
1
+ import { hooks, PreToolUse } from '../mod.js';
2
+ import { changelogPage, consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn } from '../records.js';
3
+ import { ifMissing, relativePath } from '../utils/paths.js';
4
+ import { firstLine, formatExit, listed, messageOf } from '../utils/text.js';
4
5
  import { beforeDeadline } from './deadline.js';
5
- import { answerCall, dependencyCalls, notInstalled } from './dependencies.js';
6
+ import { announcedOf, announcementMs, announcementOf } from './announce.js';
7
+ import { bind } from './bound.js';
8
+ import { serveConversations } from './conversations.js';
9
+ import { answerCall, cmodPluginName, dependencyCalls, notInstalled, withBareRefusals } from './dependencies.js';
10
+ import { keepEngine } from './engine.js';
6
11
  import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook } from './hooks.js';
7
- import { checkedAnswer, checkingGrants, checksAnswers, checkWrite, isCovered, itemOf, passedDown } from './grants.js';
8
- import { locatingPrograms } from './programs.js';
9
- import { createModFiles } from './metadata.js';
12
+ import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses } from './grants.js';
13
+ import { cmodError, lastLineOf, locatingPrograms, readLines } from './programs.js';
14
+ import { createModFiles, resolvedPath } from './metadata.js';
10
15
  import { fitsOption } from '../options.js';
11
16
  import { createInstaller } from './installer.js';
12
17
  import { createOptions, MissingOptions } from './options.js';
13
18
  import { createRouter } from './router.js';
19
+ import { conversationMethods, createSessionsClient } from './sessions.js';
20
+ import { matches } from '../utils/matcher.js';
14
21
  import { createState } from './state.js';
22
+ import { createToaster, showFailure } from './toaster.js';
15
23
  import { toolCalls } from './tool-calls.js';
16
- import { createProgress, createUi } from './ui.js';
17
- const cmodPluginName = 'cmod';
24
+ import { createUi } from './ui.js';
25
+ import { Progress } from '../ui/components.js';
26
+ import { Box, Text } from '../ui/elements.js';
18
27
  const announcedKey = 'cmod:announced';
28
+ const installId = 'cmod:install';
19
29
  const cmodCheckMs = 1000;
20
30
  const cmodWaitMs = 60_000;
21
- const sessionStartHoldMs = 10_000 - 1_000;
31
+ const startHoldMs = 10_000 - 1_000;
22
32
  const dependencyCallMs = 30_000;
23
33
  const cannotStart = /failed to start: /;
24
- const missingPath = /(?:^|: )(?:ENOENT|ENOTDIR)\b/;
25
34
  const shellTools = ['Bash', 'PowerShell'];
26
35
  export async function readPlugin(claude) {
27
36
  const { name, root } = claude.plugin;
28
37
  const read = async (path) => {
29
- const stat = await claude.fs.stat(path).catch((error) => {
30
- if (missingPath.test(messageOf(error)))
31
- return undefined;
32
- throw error;
33
- });
38
+ const stat = await claude.fs.stat(path).catch(ifMissing(undefined));
34
39
  return stat?.kind === 'file' ? claude.fs.read(path) : undefined;
35
40
  };
36
41
  const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`));
@@ -94,9 +99,9 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
94
99
  let programs;
95
100
  let plugin;
96
101
  const granted = new Set();
97
- let line;
98
102
  let activation;
99
103
  let mod;
104
+ let imports;
100
105
  let failure;
101
106
  let pluginOptions = options;
102
107
  const installer = createInstaller(definition.name, () => claude());
@@ -110,18 +115,16 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
110
115
  throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod, options) starts it at session.start.`);
111
116
  return runtime.claude;
112
117
  };
113
- const showLine = () => {
114
- line ??= runtime?.progress.start(`Installing ${definition.name}`);
115
- return line;
118
+ const installing = (description) => runtime?.toaster.toast.loading('Installing', { id: installId, ...(description === undefined ? {} : { description }) });
119
+ const toastFailure = (shown) => {
120
+ if (runtime !== undefined)
121
+ showFailure({ name: definition.name, ui: runtime.toaster, claude: runtime.claude }, shown);
116
122
  };
117
- const endLine = () => {
118
- line?.end();
119
- line = undefined;
120
- };
121
- const fail = (reason, fix) => {
123
+ const fail = (reason, fix, retry) => {
122
124
  phase = 'failed';
123
125
  failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'} ${fix}`);
124
- showLine().fail(reason, fix);
126
+ const action = retry === undefined ? {} : { action: { label: 'Retry', onClick: () => ((failure = undefined), retry().catch(report)) } };
127
+ runtime?.toaster.toast.error(reason, { id: installId, description: fix, duration: Infinity, ...action });
125
128
  };
126
129
  const finish = async () => {
127
130
  await programs?.refresh();
@@ -132,7 +135,7 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
132
135
  if (plugin === undefined || (await cmodVersion(claude())) === undefined)
133
136
  return;
134
137
  const { code, lastError } = await readLines(claude().process.spawn({ argv: ['cmod', 'setup', plugin.root, '--events'] }), (text) => {
135
- if (parseEvent(text).kind === 'done')
138
+ if (parseEvent(text).type === 'done')
136
139
  shouldRecord = false;
137
140
  });
138
141
  if (shouldRecord)
@@ -149,12 +152,12 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
149
152
  const option = definition.options?.[key];
150
153
  if (option === undefined || plugin === undefined)
151
154
  return `${definition.name} has no option ${key}.`;
152
- const value = option.kind === 'number' ? Number(text) : option.kind === 'toggle' ? text.trim() === 'true' : option.kind === 'list' ? text.split(',').map((item) => item.trim()).filter((item) => item !== '') : text;
153
- const misfit = option.kind === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value);
155
+ const value = option.type === 'number' ? Number(text) : option.type === 'toggle' ? text.trim() === 'true' : option.type === 'list' ? text.split(',').map((item) => item.trim()).filter((item) => item !== '') : text;
156
+ const misfit = option.type === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value);
154
157
  if (misfit !== undefined)
155
158
  return `${option.title} ${misfit}.`;
156
159
  const saved = await claude().process.run(['cmod', 'option', plugin.name, key, Array.isArray(value) ? value.join(',') : String(value)]);
157
- return saved.exitCode === 0 ? undefined : lastLineOf(saved.stderr) ?? `cmod option exited ${saved.exitCode}.`;
160
+ return saved.exitCode === 0 ? undefined : cmodError(saved.stderr, 'option') || `cmod option ${formatExit(saved.exitCode, '')}.`;
158
161
  };
159
162
  const askOptions = async (missing) => {
160
163
  if (!asksPerson || plugin === undefined || (await claude().session.surfaces()).length === 0)
@@ -171,21 +174,35 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
171
174
  pluginOptions = { ...pluginOptions, ...Object.fromEntries(rows.filter((row) => row.key.startsWith(prefix)).map((row) => [row.key.slice(prefix.length), row.value])) };
172
175
  return true;
173
176
  };
177
+ const isStepDone = async (active, step) => {
178
+ try {
179
+ return await step.isDone(active);
180
+ }
181
+ catch (error) {
182
+ toastFailure({ id: `step:${step.title}`, message: `The ${step.title} step's isDone failed`, error });
183
+ return false;
184
+ }
185
+ };
174
186
  const pendingSteps = async (active) => {
175
187
  const pending = [];
176
188
  for (const step of definition.installer ?? [])
177
- if (!(await Promise.resolve(step.isDone(active)).catch(() => false)))
189
+ if (!(await isStepDone(active, step)))
178
190
  pending.push(step.title);
179
191
  return pending;
180
192
  };
181
193
  const runSteps = async (active) => {
182
194
  const steps = asksPerson ? (definition.installer ?? []) : [];
183
195
  for (const [index, step] of steps.entries()) {
184
- if (await Promise.resolve(step.isDone(active)).catch(() => false))
196
+ if (await isStepDone(active, step))
185
197
  continue;
186
198
  const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length));
187
199
  if (!isAnswered) {
188
- claude().ui.log(`${definition.name} needs ${steps.length - index === 1 ? 'one more step' : `${steps.length - index} more steps`}, starting with ${step.title}. Run /mods ${definition.name} to finish.`);
200
+ runtime?.toaster.toast.warning(steps.length - index === 1 ? 'Needs one more step' : `Needs ${steps.length - index} more steps`, {
201
+ id: 'cmod:steps',
202
+ description: `Starting with ${step.title}. Finish them in /mods ${definition.name}.`,
203
+ duration: Infinity,
204
+ action: { label: 'Finish', onClick: () => active.settings.open() },
205
+ });
189
206
  break;
190
207
  }
191
208
  }
@@ -195,7 +212,7 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
195
212
  if (runtime === undefined || plugin === undefined)
196
213
  return;
197
214
  const activeRouter = createRouter();
198
- const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error) => {
215
+ const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), store: plugin.store, steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error) => {
199
216
  if (error instanceof MissingOptions && (await askOptions(error)))
200
217
  return undefined;
201
218
  fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : 'Fix it, then run /reload-plugins.');
@@ -205,48 +222,57 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
205
222
  return activate();
206
223
  const { added } = active;
207
224
  mod = active.mod;
225
+ imports = active.imports;
208
226
  router = activeRouter;
209
227
  phase = 'active';
210
228
  runtime.claude.ui.invalidate('ui.render');
211
- endLine();
212
- void runSteps(active.mod).catch((error) => claude().ui.log(`${definition.name}: its installer stopped: ${messageOf(error)}`));
229
+ void runSteps(active.mod).catch((error) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }));
213
230
  const missed = missedSessionStart;
214
231
  missedSessionStart = undefined;
215
232
  const lateAnswer = missed === undefined ? {} : await activeRouter.dispatch('classic.SessionStart', missed, async () => ({})).catch((error) => {
216
- claude().ui.log(`${definition.name}: the SessionStart hook failed: ${messageOf(error)}`);
233
+ toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error });
217
234
  return {};
218
235
  });
219
236
  if (Object.keys(lateAnswer).length > 0) {
220
237
  runtime.claude.ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read the answer of its SessionStart hooks.`, { to: 'debug' });
221
238
  }
222
239
  const version = plugin.version ?? '';
223
- if ((await runtime.claude.store.get(announcedKey)) === version)
240
+ const announced = announcedOf(await runtime.claude.store.get(announcedKey));
241
+ if (announced?.version === version) {
242
+ runtime.toaster.toast.dismiss(installId);
224
243
  return;
225
- await runtime.claude.store.set(announcedKey, version);
226
- runtime.claude.ui.toast(`${definition.name} is ready`);
227
- if (added.length > 0)
228
- runtime.claude.ui.log(`${definition.name} added ${listed(added)}.`);
244
+ }
245
+ await runtime.claude.store.set(announcedKey, { version, added });
246
+ const { toaster } = runtime;
247
+ void announcementOf(runtime.claude, plugin, granted, added, announced)
248
+ .then(({ message, ...options }) => toaster.toast.success(message, { id: installId, duration: announcementMs, ...options }))
249
+ .catch((error) => toastFailure({ id: installId, message: 'Could not say what it installed', error }));
229
250
  };
230
251
  const updatesToShow = async () => {
231
252
  const [home, configHome] = await Promise.all([claude().env.home(), claude().env.configHome()]);
232
253
  const configRoot = configHome ?? `${home ?? ''}/.claude`;
233
- const known = await claude().fs.read(`${configRoot}/plugins/known_marketplaces.json`).then((text) => JSON.parse(text), () => undefined);
254
+ const known = await claude()
255
+ .fs.read(`${configRoot}/plugins/known_marketplaces.json`)
256
+ .then((text) => JSON.parse(text))
257
+ .catch(ifMissing(undefined));
234
258
  return updatesToTurnOn(marketplaceOf(claude().plugin.root, claude().plugin.name), await claude().settings.read({ source: 'user' }), known);
235
259
  };
236
260
  const askConsent = async (event) => {
237
261
  const name = definition.name;
238
262
  if ((await claude().session.surfaces()).length === 0) {
239
- showLine().wait(`Waiting for consent: run cmod install ${name}`);
240
263
  claude().ui.log(`${name} waits for consent to run ${event.install}. Run cmod install ${name} in a terminal.`);
241
264
  return;
242
265
  }
243
- showLine().wait('Waiting for your answer');
244
- if (await installer.consent({ ...event, updates: await updatesToShow().catch(() => undefined) }))
266
+ installing('Waiting for your answer');
267
+ const updates = await updatesToShow().catch((error) => {
268
+ toastFailure({ id: 'cmod:updates', message: 'Could not read whether its marketplace updates automatically', error });
269
+ return undefined;
270
+ });
271
+ if (await installer.consent({ ...event, updates }))
245
272
  return install(event.sha256);
246
273
  await installer.close();
247
274
  phase = 'declined';
248
- endLine();
249
- claude().ui.log(`${name} is not installed. Run cmod install ${name} to install it.`);
275
+ runtime?.toaster.toast('Not installed', { id: installId, description: `Run cmod install ${name} to install it.` });
250
276
  };
251
277
  const install = async (consent) => {
252
278
  if (plugin === undefined)
@@ -257,31 +283,30 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
257
283
  const found = await cmodVersion(claude());
258
284
  if (!fits(found))
259
285
  return waitForcmod(found);
260
- endLine();
261
- const progress = showLine();
286
+ installing();
262
287
  let outcome;
263
288
  const argv = ['cmod', 'setup', plugin.root, '--events', ...(consent === undefined ? [] : ['--consent', consent])];
264
289
  const { code, lastError } = await readLines(claude().process.spawn({ argv }), (text) => {
265
290
  const event = parseEvent(text);
266
- if (event.kind === 'progress')
267
- progress.report(event);
268
- if (event.kind === 'done' || event.kind === 'failed' || event.kind === 'needs-consent')
291
+ if (event.type === 'progress')
292
+ installing(progressOf(event));
293
+ if (event.type === 'done' || event.type === 'failed' || event.type === 'needs-consent')
269
294
  outcome = event;
270
295
  });
271
- if (outcome?.kind === 'done') {
296
+ if (outcome?.type === 'done') {
272
297
  for (const item of plugin.steps.permissions ?? [])
273
298
  granted.add(item);
274
299
  return finish();
275
300
  }
276
- if (outcome?.kind === 'needs-consent')
301
+ if (outcome?.type === 'needs-consent')
277
302
  return askConsent(outcome);
278
- const reason = outcome?.kind === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`;
279
- fail(reason, `Fix the cause, then run: cmod install ${definition.name}`);
303
+ const reason = outcome?.type === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`;
304
+ fail(reason, `Fix the cause, then retry, or run cmod install ${definition.name} in a terminal.`, install);
280
305
  };
281
306
  const fits = (found) => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}));
282
307
  const waitForcmod = (found) => {
283
308
  phase = 'waiting';
284
- showLine().wait('Waiting for cmod');
309
+ installing('Waiting for cmod');
285
310
  let latest = found;
286
311
  let check;
287
312
  const stop = () => {
@@ -303,21 +328,21 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
303
328
  report(error);
304
329
  });
305
330
  });
306
- const longWait = claude().clock.after(cmodWaitMs, () => showLine().wait(latest === undefined
331
+ const longWait = claude().clock.after(cmodWaitMs, () => installing(latest === undefined
307
332
  ? "Still waiting for cmod to download cmod. See cmod's own line."
308
333
  : `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.steps ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`));
309
334
  };
310
335
  const bootstrap = async (root) => {
311
- const progress = showLine();
336
+ installing();
312
337
  phase = 'installing';
313
338
  const { code, lastError } = await readLines(claude().process.spawn({ argv: ['sh', '-c', './setup/bootstrap.sh'], cwd: root }), (text) => {
314
339
  const event = parseEvent(text);
315
- if (event.kind === 'progress')
316
- progress.report(event);
340
+ if (event.type === 'progress')
341
+ installing(progressOf(event));
317
342
  });
318
343
  if (code === 0)
319
344
  return finish();
320
- fail(`bootstrap ${formatExit(code, lastError)}`, `Run ./setup/bootstrap.sh in ${root} to see the whole log.`);
345
+ fail(`bootstrap ${formatExit(code, lastError)}`, `Run ./setup/bootstrap.sh in ${root} to see the whole log.`, () => bootstrap(root));
321
346
  };
322
347
  const report = (error) => {
323
348
  failure = error;
@@ -325,15 +350,33 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
325
350
  fail(messageOf(error), `Run cmod install ${definition.name} in a terminal to see the whole log.`);
326
351
  };
327
352
  let consentMs = 0;
328
- const consentStamp = async (path) => {
329
- const stat = await claude().fs.stat(path).catch(() => undefined);
353
+ const stampOf = async (path) => {
354
+ const stat = await claude().fs.stat(path).catch(ifMissing(undefined));
330
355
  return stat?.kind === 'file' ? stat.mtimeMs : 0;
331
356
  };
357
+ let recordMs = 0;
358
+ let readStarted;
359
+ const recover = async () => {
360
+ if (plugin === undefined || readStarted === undefined || (phase !== 'failed' && phase !== 'declined'))
361
+ return;
362
+ const modifiedMs = await stampOf(recordPath(plugin.store, plugin.name));
363
+ if (modifiedMs === recordMs)
364
+ return;
365
+ recordMs = modifiedMs;
366
+ const current = await readStarted(claude());
367
+ if (!current.isInstalled)
368
+ return;
369
+ plugin = current;
370
+ failure = undefined;
371
+ activation = undefined;
372
+ await refreshGrants();
373
+ await finish();
374
+ };
332
375
  const refreshGrants = async () => {
333
376
  if (plugin === undefined || plugin.name === cmodPluginName)
334
377
  return;
335
378
  const path = consentPath(plugin.store);
336
- const modifiedMs = await consentStamp(path);
379
+ const modifiedMs = await stampOf(path);
337
380
  if (modifiedMs === consentMs)
338
381
  return;
339
382
  consentMs = modifiedMs;
@@ -356,7 +399,7 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
356
399
  droppedParts.add(item);
357
400
  const declared = plugin?.steps.permissions ?? [];
358
401
  const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`;
359
- claude().ui.log(`${definition.name} answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it. ${fix}`);
402
+ runtime?.toaster.toast.warning(`Answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it`, { id: `dropped:${item}`, description: fix, duration: Infinity });
360
403
  },
361
404
  };
362
405
  const checkedRoute = async (event, e, next) => {
@@ -371,13 +414,15 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
371
414
  if (activation !== undefined && phase !== 'active')
372
415
  await activation;
373
416
  };
374
- const holdSessionStart = () => new Promise((resolve) => {
375
- const limit = claude().clock.after(sessionStartHoldMs, resolve);
376
- void startSettled.then(whenActive).then(() => {
377
- limit.cancel();
378
- resolve();
417
+ const holdUntilStarted = () => runtime === undefined
418
+ ? startSettled.then(whenActive)
419
+ : new Promise((resolve) => {
420
+ const limit = claude().clock.after(startHoldMs, resolve);
421
+ void startSettled.then(whenActive).then(() => {
422
+ limit.cancel();
423
+ resolve();
424
+ });
379
425
  });
380
- });
381
426
  return {
382
427
  get phase() {
383
428
  return phase;
@@ -388,11 +433,14 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
388
433
  get failure() {
389
434
  return failure;
390
435
  },
436
+ get toasts() {
437
+ return runtime?.toaster.shown ?? [];
438
+ },
391
439
  async start(claudeCalls, read) {
392
440
  if (runtime !== undefined)
393
441
  return;
394
442
  programs = locatingPrograms(claudeCalls);
395
- runtime = { claude: programs.claude, progress: createProgress(claudeCalls) };
443
+ runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name) };
396
444
  try {
397
445
  await programs.refresh();
398
446
  const started = await read(claudeCalls).catch((error) => {
@@ -402,9 +450,11 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
402
450
  if (started === undefined)
403
451
  return;
404
452
  plugin = started;
453
+ readStarted = read;
405
454
  for (const item of started.granted)
406
455
  granted.add(item);
407
- consentMs = await consentStamp(consentPath(started.store));
456
+ consentMs = await stampOf(consentPath(started.store));
457
+ recordMs = await stampOf(recordPath(started.store, started.name));
408
458
  if (started.isInstalled) {
409
459
  shouldRecord = started.shouldRecord;
410
460
  if (shouldRecord)
@@ -420,6 +470,8 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
420
470
  }
421
471
  },
422
472
  async route(event, e, next) {
473
+ if (imports !== undefined)
474
+ bind(imports);
423
475
  if (event === 'ui.render' && installer.draws(e))
424
476
  return installer.draw(e);
425
477
  if (event === 'ui.close')
@@ -432,10 +484,13 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
432
484
  }
433
485
  if (shouldRecord && event === 'classic.UserPromptSubmit')
434
486
  record();
435
- await (event === 'classic.SessionStart' ? holdSessionStart() : whenActive());
487
+ if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart')
488
+ await recover().catch((error) => claude().ui.log(`${definition.name} stays stopped: ${messageOf(error)}`, { to: 'debug' }));
489
+ const isCallToThisMod = event === 'cmod.call' && e.to === definition.name;
490
+ await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive());
436
491
  if (event === 'classic.SessionStart' && phase !== 'active')
437
492
  missedSessionStart = e;
438
- if (event === 'cmod.call' && phase !== 'active' && e.to === definition.name) {
493
+ if (isCallToThisMod && phase !== 'active') {
439
494
  const isStopped = phase === 'declined' || phase === 'failed';
440
495
  return { deny: isStopped ? notInstalled(definition.name) : `${definition.name} is installing. Try again when it's ready.` };
441
496
  }
@@ -452,17 +507,18 @@ export function createLifecycle(definition, checksPermissions = () => true, opti
452
507
  return checkedRoute(event, e, next);
453
508
  const answer = router.dispatch(event, e, next);
454
509
  const render = e;
455
- if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined || !runtime.progress.isShown)
510
+ if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined)
456
511
  return answer;
457
- return runtime.progress.draw((await answer), runtime.claude.ui.resolve(render), render.viewport?.columns);
512
+ return runtime.toaster.draw((await answer), runtime.claude.ui.resolve(render), render.props);
458
513
  },
459
514
  };
460
515
  }
461
516
  async function createMod(definition, runtime) {
462
- const { router, progress } = runtime;
463
- const claude = checkingKeys(runtime.claude, definition.name, runtime.steps.keys ?? {});
517
+ const { router, toaster } = runtime;
518
+ const report = (failure) => void showFailure({ name: definition.name, ui: toaster, claude: runtime.claude }, failure);
519
+ const claude = withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {}));
464
520
  const added = [];
465
- const hookEvents = [];
521
+ const hooksAdded = [];
466
522
  const announce = (feature) => {
467
523
  added.push(feature);
468
524
  };
@@ -478,19 +534,24 @@ async function createMod(definition, runtime) {
478
534
  configRoot,
479
535
  projectRoot: () => modState.root,
480
536
  freeFolders: () => [modState.root, runtime.dataFolder],
537
+ freeAddresses: () => programAddresses(mod),
481
538
  };
482
539
  const checked = checkingGrants(claude, grants);
540
+ const places = { home, configRoot, projectRoot: () => modState.root };
483
541
  const files = createModFiles({
484
542
  name: definition.name,
485
543
  claude,
486
- places: { home, configRoot, projectRoot: () => modState.root },
544
+ places,
487
545
  checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
546
+ report,
488
547
  });
489
548
  const pages = new Map();
490
549
  const settingsPages = {
491
550
  page(page) {
492
551
  if (pages.has(page.id))
493
552
  throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`);
553
+ if (page.id === changelogPage)
554
+ throw new Error(`${definition.name}: the settings page id "${changelogPage}" opens the mod's CHANGELOG.md in /mods. Give the page another id.`);
494
555
  pages.set(page.id, { title: page.title, handle: area.ui.pane(page) });
495
556
  },
496
557
  async open(pageId) {
@@ -507,87 +568,147 @@ async function createMod(definition, runtime) {
507
568
  let cwd = startCwd;
508
569
  let loadedCwd = startCwd;
509
570
  let staleCwd;
510
- const area = createUi({ name: definition.name, claude, router, progress, announce, mod: () => mod });
511
- const modState = createState({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed });
512
- const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, changed: modState.changed });
571
+ let forkedFrom;
572
+ let parentAsked;
573
+ let hasEarlierPrompts = true;
574
+ const area = createUi({ name: definition.name, claude, router, toaster, report, announce, mod: () => mod });
575
+ const askParent = async () => {
576
+ if (definition.name === cmodPluginName)
577
+ return undefined;
578
+ try {
579
+ const parent = await claude.cmod.call({ to: cmodPluginName, method: 'forkParent', input: null });
580
+ return typeof parent === 'string' ? parent : undefined;
581
+ }
582
+ catch (error) {
583
+ claude.ui.log(`${definition.name} starts without the session values of the conversation it was forked from: ${messageOf(error)}`, { to: 'debug' });
584
+ return undefined;
585
+ }
586
+ };
587
+ const forkParent = () => (parentAsked ??= askParent().then((parent) => {
588
+ forkedFrom = parent;
589
+ return parent;
590
+ }));
591
+ const modState = createState({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent });
592
+ const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed });
513
593
  const held = runtime.checksPermissions() ? new Map() : undefined;
514
- const mod = {
515
- name: definition.name,
516
- state: modState.state,
517
- get options() {
518
- return modOptions.values;
519
- },
520
- dataFolder: runtime.dataFolder,
521
- on(event, hook, options) {
522
- const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude);
523
- if (!hookEvents.includes(event))
524
- hookEvents.push(event);
525
- if (event === 'PreToolUse')
526
- router.add('tool.call', preToolUseHook(definition.name, bounded, claude, agents, held));
527
- else
528
- router.add(`classic.${event}`, classicHook(definition.name, event, bounded, claude, agents));
529
- },
530
- use: (job) => job({ mod, announce, reserveName, toolCalls: agents }),
531
- every(ms, hook) {
532
- let isRunning = false;
533
- return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
534
- if (isRunning)
535
- return;
536
- isRunning = true;
537
- void Promise.resolve()
538
- .then(hook)
539
- .catch((error) => claude.ui.log(`${definition.name}: the every ${ms} ms hook failed: ${messageOf(error)}`))
540
- .finally(() => {
541
- isRunning = false;
542
- });
543
- });
544
- },
545
- ui: area.ui,
546
- process: {
547
- run: (argv, init) => checked.process.run(argv, init),
548
- spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
594
+ const expand = (path) => (path === '~' || path.startsWith('~/') ? resolvedPath(path, places) : path);
595
+ const settingsFile = (source) => ({
596
+ read: () => checked.settings.read({ source }),
597
+ async update(values) {
598
+ await checkSettings(grants, source, Object.keys(values));
599
+ await claude.cmod.call({ to: cmodPluginName, method: 'updateSettings', input: { mod: definition.name, source, project: modState.root, values } });
549
600
  },
601
+ });
602
+ const client = createSessionsClient({ name: definition.name, claude: checked, report: (id, message, error) => report({ id, message, error }) });
603
+ let followed;
604
+ const followFolders = (project, folder) => {
605
+ if (followed === undefined)
606
+ return;
607
+ followed = { ...followed, project, cwd: folder };
608
+ client.follow(followed);
609
+ };
610
+ const imports = {
611
+ sessions: client.sessions,
612
+ settings: { user: settingsFile('user'), project: settingsFile('project'), local: settingsFile('local') },
613
+ model: { complete: (completion, options) => checked.model.complete(completion, options) },
550
614
  fs: {
551
- read: (path) => checked.fs.read(path),
552
- write: (path, text) => checked.fs.write(path, text),
553
- list: (path) => checked.fs.list(path),
554
- exists: (path) => checked.fs.exists(path),
555
- stat: (path, options) => checked.fs.stat(path, options),
615
+ read: (path) => checked.fs.read(expand(path)),
616
+ write: (path, text) => checked.fs.write(expand(path), text),
617
+ exists: (path) => checked.fs.exists(expand(path)),
556
618
  find: (glob) => files.find(glob),
619
+ list: async (path) => (await checked.fs.list(path === undefined ? undefined : expand(path))).map(({ name, kind, size, mtimeMs, isLink }) => ({ name, isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs })),
620
+ async stat(path, options) {
621
+ const { kind, size, mtimeMs, isLink, realPath } = await checked.fs.stat(expand(path), options);
622
+ return { isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs, ...(realPath === undefined ? {} : { realPath }) };
623
+ },
557
624
  },
558
625
  metadata: {
559
626
  read: (path) => files.read(path),
560
627
  update: (path, change) => files.update(path, change),
561
628
  },
629
+ process: {
630
+ run: (argv, init) => checked.process.run(argv, init),
631
+ spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
632
+ },
562
633
  http: { fetch: (url, init) => checked.http.fetch(url, init) },
563
- settings: settingsPages,
564
- session: {
565
- messages: ((args) => (args === undefined ? checked.session.messages() : checked.session.messages({ agentId: args.agentId }))),
566
- async append(text) {
567
- const added = await checked.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } });
568
- if (added.deny !== undefined)
569
- throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`);
570
- },
571
- async submit(text) {
572
- const submitted = await checked.prompt.submit({ text });
573
- if ('drop' in submitted && typeof submitted.drop === 'string')
574
- throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`);
575
- },
634
+ };
635
+ const allowed = (permission) => ({ allows: (value) => isCovered(granted, itemOf(permission, value), home) });
636
+ const flag = (permission) => isCovered(granted, permission, home);
637
+ const timer = (ms, subject, start, listener) => {
638
+ let isRunning = false;
639
+ const started = start(checkedMs(definition.name, `mod.${subject}`, ms), () => {
640
+ if (isRunning)
641
+ return;
642
+ isRunning = true;
643
+ void Promise.resolve()
644
+ .then(() => {
645
+ bind(imports);
646
+ return listener();
647
+ })
648
+ .catch((error) => report({ id: `${subject}:${ms}`, message: `The ${subject} ${ms} ms timer failed`, error }))
649
+ .finally(() => {
650
+ isRunning = false;
651
+ });
652
+ });
653
+ return { dispose: () => started.cancel() };
654
+ };
655
+ const mod = {
656
+ name: definition.name,
657
+ folder: claude.plugin.root,
658
+ dataFolder: runtime.dataFolder,
659
+ state: modState.state,
660
+ get options() {
661
+ return modOptions.values;
576
662
  },
577
- agent: { spawn: (args) => checked.agent.spawn(args) },
578
- model: { complete: (completion, options) => checked.model.complete(completion, options) },
579
663
  permissions: {
580
- has: (name, value) => isCovered(granted, itemOf(name, value), home),
581
- },
582
- claude: { ...checked, on: (event, hook) => router.add(event, hook) },
583
- get projectRoot() {
584
- return modState.root;
664
+ get conversation() {
665
+ return flag('conversation');
666
+ },
667
+ get prompt() {
668
+ return flag('prompt');
669
+ },
670
+ get model() {
671
+ return flag('model');
672
+ },
673
+ get tasks() {
674
+ return flag('tasks');
675
+ },
676
+ get tools() {
677
+ return flag('tools');
678
+ },
679
+ get approve() {
680
+ return flag('approve');
681
+ },
682
+ network: allowed('network'),
683
+ run: allowed('run'),
684
+ files: allowed('files'),
685
+ config: allowed('config'),
585
686
  },
586
- get cwd() {
587
- return cwd;
687
+ on(hook, matcherOrListener, listenerOrOptions, maybeOptions) {
688
+ const hasMatcher = typeof listenerOrOptions === 'function';
689
+ const listener = (hasMatcher ? listenerOrOptions : matcherOrListener);
690
+ const matcher = hasMatcher ? matcherOrListener : undefined;
691
+ const options = (hasMatcher ? maybeOptions : listenerOrOptions);
692
+ if (!hooks.includes(hook))
693
+ throw new Error(`${definition.name}: mod.on takes one of Claude Code's hooks, such as PreToolUse, not ${String(hook)}.`);
694
+ const matched = matcher === undefined ? listener : (input) => (matches(input, matcher) ? listener(input) : undefined);
695
+ const bounded = options?.timeoutMs === undefined ? matched : timedHook(hook, matched, checkedMs(definition.name, `the ${hook} hook's timeoutMs`, options.timeoutMs), claude, toaster.toast);
696
+ if (!hooksAdded.includes(hook))
697
+ hooksAdded.push(hook);
698
+ if (hook === PreToolUse)
699
+ return router.add('tool.call', preToolUseHook(definition.name, bounded, claude, agents, held));
700
+ const classic = hook;
701
+ return router.add(`classic.${classic}`, classicHook(definition.name, classic, bounded, claude, agents, report));
588
702
  },
703
+ every: (ms, listener) => timer(ms, 'every', claude.clock.every, listener),
704
+ after: (ms, listener) => timer(ms, 'after', claude.clock.after, listener),
705
+ use: (job) => job({ mod, announce, reserveName }),
706
+ log: (text) => claude.ui.log(`${definition.name}: ${text}`, { to: 'debug' }),
707
+ ui: { ...area.ui, notice: (callId, text) => claude.ui.notice(callId, text) },
708
+ settings: settingsPages,
589
709
  dependencies: dependencyCalls(claude, (call, task) => beforeDeadline(claude, { ms: dependencyCallMs }, call, task)),
590
710
  };
711
+ bind(imports);
591
712
  const followSession = async (isAfterCd = false) => {
592
713
  try {
593
714
  const [nextRoot, reportedCwd] = await Promise.all([claude.session.root(), claude.session.cwd()]);
@@ -601,9 +722,11 @@ async function createMod(definition, runtime) {
601
722
  if (!hasMovedRoot && nextCwd === oldCwd)
602
723
  return;
603
724
  cwd = nextCwd;
725
+ followFolders(nextRoot, nextCwd);
604
726
  await modState.moveTo(nextRoot).catch((error) => {
605
727
  if (cwd === nextCwd)
606
728
  cwd = loadedCwd;
729
+ followFolders(modState.root, cwd);
607
730
  throw error;
608
731
  });
609
732
  if (hasMovedRoot)
@@ -617,18 +740,34 @@ async function createMod(definition, runtime) {
617
740
  await router.dispatch('classic.CwdChanged', moved, async () => ({}));
618
741
  }
619
742
  catch (error) {
620
- claude.ui.log(`${definition.name} keeps the state of ${modState.root} until the next prompt or folder move: ${messageOf(error)}`);
743
+ report({ id: 'cmod:follow', message: `Keeps the state of ${modState.root} until the next prompt or folder move`, error });
621
744
  }
622
745
  };
746
+ const followCurrent = async (title) => {
747
+ const [id, model, usage] = await Promise.all([claude.session.id(), claude.session.model(), claude.session.usage()]);
748
+ followed = { id, title: title ?? '', project: modState.root, cwd, model, startedAt: new Date(usage.startedAt).toISOString(), ...(forkedFrom === undefined ? {} : { forkedFrom }) };
749
+ client.follow(followed);
750
+ };
623
751
  router.add('classic.SessionStart', async (e, next) => {
624
- await modState.switchSession(e.session_id, e.source).catch((error) => {
625
- claude.ui.log(`${definition.name} kept its session values from before the ${e.source}: ${messageOf(error)}`);
752
+ const previous = followed?.id;
753
+ if (e.source === 'fork' && previous !== undefined && previous !== e.session_id)
754
+ forkedFrom = previous;
755
+ else if (e.session_id !== previous) {
756
+ forkedFrom = undefined;
757
+ parentAsked = undefined;
758
+ }
759
+ hasEarlierPrompts = e.source !== 'startup' && e.source !== 'clear';
760
+ await modState.switchSession(e.session_id, e.source, e.transcript_path).catch((error) => {
761
+ report({ id: 'cmod:session', message: `Kept its session values from before the ${e.source}`, error });
626
762
  });
763
+ await followCurrent(e.session_title).catch((error) => report({ id: 'cmod:current', message: 'Kept the details of the session from before', error }));
627
764
  return next(e);
628
765
  });
629
766
  router.add('classic.UserPromptSubmit', async (e, next) => {
630
767
  staleCwd = undefined;
631
768
  await followSession();
769
+ const isPerson = e.source === undefined || e.source === 'user' || e.source === 'sdk';
770
+ await followCurrent(e.session_title || (isPerson && !hasEarlierPrompts && client.sessions.current.title === '' ? firstLine(e.prompt) : undefined)).catch((error) => report({ id: 'cmod:current', message: 'Kept the details of the session from before', error }));
632
771
  return next(e);
633
772
  });
634
773
  for (const event of ['classic.PostToolUse', 'classic.PostToolUseFailure']) {
@@ -646,50 +785,63 @@ async function createMod(definition, runtime) {
646
785
  });
647
786
  router.add('skill.prompt', userSkillHook(claude));
648
787
  const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input) => run(input, mod)]));
788
+ const conversations = definition.name === cmodPluginName ? serveConversations({ claude, router, store: runtime.store, project: () => modState.root, report: (id, message, error) => report({ id, message, error }) }) : undefined;
649
789
  router.add('cmod.call', async (e, next) => {
650
790
  if (e.to !== definition.name)
651
791
  return next(e);
792
+ const call = e;
793
+ if (e.method === conversationMethods.changed && call.caller === cmodPluginName) {
794
+ if (!flag('conversation'))
795
+ return { value: null };
796
+ const { session: changedSession, change } = e.input;
797
+ client.changed(changedSession, change);
798
+ return { value: null };
799
+ }
652
800
  if (e.method === settingsPagesMethod)
653
801
  return { value: [...pages].map(([id, { title }]) => ({ id, title })) };
654
802
  if (e.method === openPageMethod) {
655
803
  await settingsPages.open(String(e.input));
656
804
  return { value: null };
657
805
  }
658
- return answerCall(definition.name, api, e);
806
+ const served = await conversations?.answer(call);
807
+ return served ?? answerCall(definition.name, api, e);
659
808
  });
660
809
  const agents = toolCalls(claude, router);
810
+ keepEngine(mod, { claude: checked, router, toolCalls: agents, report });
661
811
  const names = new Set();
662
- const reserveName = (kind, name, taken) => {
663
- const key = `${kind}:${name}`;
812
+ const reserveName = (type, name, taken) => {
813
+ const key = `${type}:${name}`;
664
814
  if (names.has(key))
665
815
  throw new Error(taken);
666
816
  names.add(key);
817
+ return { dispose: () => void names.delete(key) };
667
818
  };
668
819
  await failsAs('its state did not load', () => modState.load());
669
820
  await failsAs('its options did not load', () => modOptions.load(root));
670
821
  const missing = modOptions.missing;
671
822
  if (missing.length > 0)
672
823
  throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key));
824
+ await failsAs('the details of its session did not load', () => followCurrent(undefined));
673
825
  await failsAs('its setup function threw', () => definition.setup(mod));
674
826
  const permissionHooks = permissionEvents.filter((event) => router.has(event));
675
827
  if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
676
828
  throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`);
677
829
  }
678
- if (held !== undefined && hookEvents.includes('PreToolUse'))
830
+ if (held !== undefined && hooksAdded.includes(PreToolUse))
679
831
  router.add('tool.check', heldDecisionHook(held));
680
832
  await failsAs('its open panes did not load', () => area.restorePanes());
681
- if (hookEvents.length > 0)
682
- added.push(`${hookEvents.length === 1 ? 'a hook' : 'hooks'} on ${listed(hookEvents)}`);
683
- return { mod, added };
833
+ if (hooksAdded.length > 0)
834
+ added.push(`${hooksAdded.length === 1 ? 'a hook' : 'hooks'} on ${listed(hooksAdded)}`);
835
+ return { mod, added, imports };
684
836
  }
685
- function checkingKeys(claude, name, keys) {
837
+ function checkingKeys(claude, toast, keys) {
686
838
  const bound = Object.entries(keys);
687
839
  if (bound.length === 0)
688
840
  return claude;
689
841
  const register = (command) => {
690
842
  for (const [key, commandName] of bound) {
691
843
  if (commandName === command.name && command.immediate !== true) {
692
- claude.ui.log(`${name}: ${key} runs /${command.name}, which waits for Claude's turn to end and adds a row to the conversation. Give /${command.name} immediate: true.`);
844
+ toast.warning(`${key} runs /${command.name}, which waits for Claude's turn to end`, { id: `key:${key}`, description: `It adds a row to the conversation. Give /${command.name} immediate: true.`, duration: Infinity });
693
845
  }
694
846
  }
695
847
  return claude.command.register(command);
@@ -702,10 +854,10 @@ function checkedMs(name, subject, ms) {
702
854
  throw new Error(`${name}: ${subject} is ${ms}. Give a whole number of milliseconds above 0 and at most ${longestTimerMs}.`);
703
855
  return ms;
704
856
  }
705
- function timedHook(name, event, hook, ms, claude) {
857
+ function timedHook(event, hook, ms, claude, toast) {
706
858
  return (input) => new Promise((resolve, reject) => {
707
859
  const timer = claude.clock.after(ms, () => {
708
- claude.ui.log(`${name}: the ${event} hook passed its ${ms / 1000} s timeout`);
860
+ toast.warning(`The ${event} hook passed its ${ms / 1000} s timeout`, { id: `timeout:${event}` });
709
861
  resolve(undefined);
710
862
  });
711
863
  Promise.resolve()
@@ -727,27 +879,6 @@ async function failsAs(subject, step) {
727
879
  throw new Error(`${subject}: ${messageOf(error)}`, { cause: error });
728
880
  }
729
881
  }
730
- async function readLines(stream, onLine) {
731
- let pending = '';
732
- let lastError = '';
733
- let piece = await stream.next();
734
- while (piece.done !== true) {
735
- if (piece.value.stream === 'stderr') {
736
- lastError = lastLineOf(piece.value.text) ?? lastError;
737
- }
738
- else {
739
- const lines = `${pending}${piece.value.text}`.split('\n');
740
- pending = lines.pop() ?? '';
741
- for (const text of lines)
742
- onLine(text);
743
- }
744
- piece = await stream.next();
745
- }
746
- if (pending !== '')
747
- onLine(pending);
748
- return { code: piece.value.code, lastError };
749
- }
750
- function lastLineOf(text) {
751
- const trimmed = text.trim();
752
- return trimmed === '' ? undefined : trimmed.slice(trimmed.lastIndexOf('\n') + 1).trim();
882
+ function progressOf({ done, total, label }) {
883
+ return Box({ gap: 2, children: [Progress({ value: total > 0 ? (done / total) * 100 : 0 }), Text({ dimColor: true, wrap: 'truncate-end', children: label })] });
753
884
  }