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