@cmodjs/core 3.1.0 → 4.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 (200) hide show
  1. package/docs/commands.md +38 -21
  2. package/docs/dependencies.md +27 -20
  3. package/docs/index.md +21 -17
  4. package/docs/install-steps.md +37 -29
  5. package/docs/jobs.md +25 -52
  6. package/docs/mod.md +93 -65
  7. package/docs/options.md +2 -2
  8. package/docs/permissions.md +4 -4
  9. package/docs/session.md +14 -12
  10. package/docs/testing.md +55 -32
  11. package/docs/ui.md +255 -263
  12. package/hotkeys.d.ts +22 -0
  13. package/hotkeys.d.ts.map +1 -0
  14. package/hotkeys.js +26 -0
  15. package/installer.d.ts +1 -1
  16. package/installer.d.ts.map +1 -1
  17. package/installer.js +11 -6
  18. package/jobs/check.d.ts +2 -1
  19. package/jobs/check.d.ts.map +1 -1
  20. package/jobs/check.js +2 -2
  21. package/jobs/context.d.ts +2 -1
  22. package/jobs/context.d.ts.map +1 -1
  23. package/jobs/context.js +1 -1
  24. package/jobs/permissions.d.ts +2 -1
  25. package/jobs/permissions.d.ts.map +1 -1
  26. package/jobs/permissions.js +2 -2
  27. package/jobs/program.js +1 -1
  28. package/jobs/prompt.d.ts +2 -1
  29. package/jobs/prompt.d.ts.map +1 -1
  30. package/jobs/prompt.js +20 -13
  31. package/jobs/schedule.d.ts +2 -1
  32. package/jobs/schedule.d.ts.map +1 -1
  33. package/jobs/schedule.js +15 -1
  34. package/jobs/slash-command.d.ts +6 -2
  35. package/jobs/slash-command.d.ts.map +1 -1
  36. package/jobs/slash-command.js +6 -5
  37. package/jobs/tool.d.ts +6 -4
  38. package/jobs/tool.d.ts.map +1 -1
  39. package/jobs/tool.js +3 -3
  40. package/mod.d.ts +63 -42
  41. package/mod.d.ts.map +1 -1
  42. package/mod.js +11 -1
  43. package/package.json +1 -1
  44. package/records.d.ts +13 -8
  45. package/records.d.ts.map +1 -1
  46. package/records.js +41 -31
  47. package/register.d.ts.map +1 -1
  48. package/register.js +1 -2
  49. package/runtime/announce.d.ts +1 -1
  50. package/runtime/announce.d.ts.map +1 -1
  51. package/runtime/announce.js +6 -7
  52. package/runtime/before.d.ts +2 -2
  53. package/runtime/before.d.ts.map +1 -1
  54. package/runtime/before.js +1 -1
  55. package/runtime/claude.d.ts +0 -1
  56. package/runtime/claude.d.ts.map +1 -1
  57. package/runtime/conversations.d.ts +4 -1
  58. package/runtime/conversations.d.ts.map +1 -1
  59. package/runtime/conversations.js +42 -14
  60. package/runtime/dependencies.d.ts.map +1 -1
  61. package/runtime/dependencies.js +2 -1
  62. package/runtime/engine.d.ts +5 -0
  63. package/runtime/engine.d.ts.map +1 -1
  64. package/runtime/grants.js +2 -2
  65. package/runtime/hooks.d.ts +1 -1
  66. package/runtime/hooks.d.ts.map +1 -1
  67. package/runtime/hotkeys.d.ts +15 -0
  68. package/runtime/hotkeys.d.ts.map +1 -0
  69. package/runtime/hotkeys.js +58 -0
  70. package/runtime/installer.d.ts +5 -2
  71. package/runtime/installer.d.ts.map +1 -1
  72. package/runtime/installer.js +23 -12
  73. package/runtime/lifecycle.d.ts.map +1 -1
  74. package/runtime/lifecycle.js +190 -141
  75. package/runtime/options.js +2 -2
  76. package/runtime/pages.d.ts +28 -0
  77. package/runtime/pages.d.ts.map +1 -0
  78. package/runtime/pages.js +98 -0
  79. package/runtime/parts.d.ts +14 -0
  80. package/runtime/parts.d.ts.map +1 -0
  81. package/runtime/parts.js +63 -0
  82. package/runtime/permission-check.d.ts +2 -2
  83. package/runtime/permission-check.d.ts.map +1 -1
  84. package/runtime/programs.js +1 -1
  85. package/runtime/router.d.ts +2 -2
  86. package/runtime/router.d.ts.map +1 -1
  87. package/runtime/router.js +1 -1
  88. package/runtime/sessions.d.ts +10 -9
  89. package/runtime/sessions.d.ts.map +1 -1
  90. package/runtime/sessions.js +14 -5
  91. package/runtime/state.d.ts +2 -1
  92. package/runtime/state.d.ts.map +1 -1
  93. package/runtime/state.js +1 -0
  94. package/runtime/toaster.d.ts +1 -1
  95. package/runtime/toaster.d.ts.map +1 -1
  96. package/runtime/toaster.js +10 -4
  97. package/runtime/ui.d.ts +87 -5
  98. package/runtime/ui.d.ts.map +1 -1
  99. package/runtime/ui.js +372 -109
  100. package/runtime/watches.d.ts +9 -0
  101. package/runtime/watches.d.ts.map +1 -0
  102. package/runtime/watches.js +73 -0
  103. package/runtime/widgets.d.ts +48 -0
  104. package/runtime/widgets.d.ts.map +1 -0
  105. package/runtime/widgets.js +219 -0
  106. package/session/context/turn/message/message.d.ts.map +1 -1
  107. package/session/context/turn/message/message.js +7 -2
  108. package/session/session.d.ts +11 -5
  109. package/session/session.d.ts.map +1 -1
  110. package/src/hotkeys.ts +51 -0
  111. package/src/installer.ts +8 -4
  112. package/src/jobs/check.ts +4 -3
  113. package/src/jobs/context.ts +3 -2
  114. package/src/jobs/permissions.ts +4 -3
  115. package/src/jobs/program.ts +1 -1
  116. package/src/jobs/prompt.ts +23 -14
  117. package/src/jobs/schedule.ts +16 -2
  118. package/src/jobs/slash-command.ts +10 -7
  119. package/src/jobs/tool.ts +7 -5
  120. package/src/mod.ts +59 -47
  121. package/src/records.ts +46 -31
  122. package/src/register.ts +2 -3
  123. package/src/runtime/announce.ts +6 -7
  124. package/src/runtime/before.ts +4 -4
  125. package/src/runtime/claude.ts +0 -1
  126. package/src/runtime/conversations.ts +66 -20
  127. package/src/runtime/dependencies.ts +2 -1
  128. package/src/runtime/engine.ts +5 -0
  129. package/src/runtime/grants.ts +2 -2
  130. package/src/runtime/hooks.ts +1 -0
  131. package/src/runtime/hotkeys.ts +80 -0
  132. package/src/runtime/installer.ts +24 -12
  133. package/src/runtime/lifecycle.ts +189 -136
  134. package/src/runtime/options.ts +2 -2
  135. package/src/runtime/pages.ts +127 -0
  136. package/src/runtime/parts.ts +72 -0
  137. package/src/runtime/permission-check.ts +2 -2
  138. package/src/runtime/programs.ts +1 -1
  139. package/src/runtime/router.ts +3 -3
  140. package/src/runtime/sessions.ts +24 -15
  141. package/src/runtime/state.ts +3 -1
  142. package/src/runtime/toaster.ts +10 -4
  143. package/src/runtime/ui.ts +405 -115
  144. package/src/runtime/watches.ts +75 -0
  145. package/src/runtime/widgets.ts +287 -0
  146. package/src/session/context/turn/message/message.ts +7 -2
  147. package/src/session/session.ts +10 -6
  148. package/src/testing/fake-claude.ts +9 -6
  149. package/src/testing/fake-elements.ts +7 -4
  150. package/src/testing/fake-sessions.ts +6 -4
  151. package/src/testing.ts +203 -46
  152. package/src/ui/areas.ts +27 -0
  153. package/src/ui/elements.ts +35 -0
  154. package/src/ui/markdown.ts +18 -18
  155. package/src/ui/panes.ts +73 -0
  156. package/src/ui/styles.ts +53 -0
  157. package/src/utils/subscription.ts +1 -0
  158. package/testing/fake-claude.d.ts +0 -1
  159. package/testing/fake-claude.d.ts.map +1 -1
  160. package/testing/fake-claude.js +9 -5
  161. package/testing/fake-elements.js +7 -4
  162. package/testing/fake-sessions.d.ts +2 -1
  163. package/testing/fake-sessions.d.ts.map +1 -1
  164. package/testing/fake-sessions.js +1 -0
  165. package/testing.d.ts +21 -6
  166. package/testing.d.ts.map +1 -1
  167. package/testing.js +206 -45
  168. package/ui/areas.d.ts +29 -0
  169. package/ui/areas.d.ts.map +1 -0
  170. package/ui/areas.js +6 -0
  171. package/ui/elements.d.ts +3 -0
  172. package/ui/elements.d.ts.map +1 -1
  173. package/ui/elements.js +36 -0
  174. package/ui/markdown.d.ts +14 -14
  175. package/ui/markdown.d.ts.map +1 -1
  176. package/ui/markdown.js +11 -11
  177. package/ui/panes.d.ts +71 -0
  178. package/ui/panes.d.ts.map +1 -0
  179. package/ui/panes.js +16 -0
  180. package/ui/styles.d.ts +44 -0
  181. package/ui/styles.d.ts.map +1 -0
  182. package/ui/styles.js +24 -0
  183. package/utils/subscription.d.ts +4 -0
  184. package/utils/subscription.d.ts.map +1 -0
  185. package/jobs/status-line.d.ts +0 -6
  186. package/jobs/status-line.d.ts.map +0 -1
  187. package/jobs/status-line.js +0 -39
  188. package/src/jobs/status-line.ts +0 -44
  189. package/src/ui/define-pane.ts +0 -27
  190. package/src/ui/places.ts +0 -49
  191. package/src/utils/disposable.ts +0 -3
  192. package/ui/define-pane.d.ts +0 -15
  193. package/ui/define-pane.d.ts.map +0 -1
  194. package/ui/define-pane.js +0 -12
  195. package/ui/places.d.ts +0 -43
  196. package/ui/places.d.ts.map +0 -1
  197. package/ui/places.js +0 -31
  198. package/utils/disposable.d.ts +0 -4
  199. package/utils/disposable.d.ts.map +0 -1
  200. /package/utils/{disposable.js → subscription.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { changelogPage, consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn } from '../records.js';
1
+ import { consentPath, dataFolder, finishStepsMethod, grantFix, coreVersion, isAtLeast, marketplaceOf, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, storeFolder, updatesToTurnOn } from '../records.js';
2
2
  import { homePrefix, ifMissing, relativePath } from '../utils/paths.js';
3
3
  import { firstLine, formatExit, messageOf } from '../utils/text.js';
4
4
  import { beforeDeadline } from './deadline.js';
@@ -9,7 +9,7 @@ import { answerCall, answeringOwnCalls, cmodPluginName, dependencyCalls, withBar
9
9
  import { keepEngine } from './engine.js';
10
10
  import { classicFailure, classicHook, userSkillHook } from './hooks.js';
11
11
  import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses } from './grants.js';
12
- import { cmodError, lastLineOf, locatingPrograms, readLines } from './programs.js';
12
+ import { cmodError, locatingPrograms, readLines } from './programs.js';
13
13
  import { createModFiles, resolvedPath } from './metadata.js';
14
14
  import { fitsOption } from '../options.js';
15
15
  import { createInstaller } from './installer.js';
@@ -20,11 +20,19 @@ import { matches } from '../utils/matcher.js';
20
20
  import { createState } from './state.js';
21
21
  import { createToaster } from './toaster.js';
22
22
  import { toolCalls } from './tool-calls.js';
23
- import { createUi } from './ui.js';
23
+ import { createHotkeys } from './hotkeys.js';
24
+ import { servePages } from './pages.js';
25
+ import { drawnParts } from './parts.js';
26
+ import { createWatches } from './watches.js';
27
+ import { createUi, partMethods } from './ui.js';
28
+ import { serveWidgets } from './widgets.js';
24
29
  import { Progress } from '../ui/components.js';
25
30
  import { Box, Text } from '../ui/elements.js';
26
31
  const announcedKey = 'cmod:announced';
32
+ const cmodUpdatedKey = 'cmod:cmod-updated-for';
27
33
  const installId = 'cmod:install';
34
+ const askingId = 'cmod:asking';
35
+ const recordId = 'cmod:record';
28
36
  const installRetrySeconds = [2, 4, 8, 16, 32, 64, 128];
29
37
  const cmodCheckMs = 1000;
30
38
  const cmodWaitMs = 60_000;
@@ -32,7 +40,6 @@ const cmodSlowCheckMs = 10_000;
32
40
  const hookMs = 10_000;
33
41
  const startHoldMs = hookMs - 1_000;
34
42
  const dependencyCallMs = 30_000;
35
- const cannotStart = /failed to start: /;
36
43
  const shellTools = ['Bash', 'PowerShell'];
37
44
  export async function readPlugin(claude) {
38
45
  const { name, root } = claude.plugin;
@@ -58,7 +65,10 @@ export async function readPlugin(claude) {
58
65
  read: (path) => read(`${root}/${path}`),
59
66
  list: (folder) => filesBelow(claude, `${root}/${folder}`),
60
67
  });
61
- return { ...plugin, isInstalled: version !== undefined && record?.version === version && record.scriptsSha256 === scripts, shouldRecord: false };
68
+ if (version === undefined || record?.scriptsSha256 !== scripts)
69
+ return { ...plugin, isInstalled: false, shouldRecord: false };
70
+ const isInstalled = record.version === version || record.root === root;
71
+ return { ...plugin, isInstalled, shouldRecord: isInstalled && record.version !== version };
62
72
  }
63
73
  async function filesBelow(claude, folder) {
64
74
  const entries = await claude.fs.list(folder);
@@ -83,17 +93,18 @@ async function readJson(read, path) {
83
93
  }
84
94
  }
85
95
  async function cmodVersion(claude) {
86
- const result = await claude.process.run(['cmod', '--version']).catch((error) => {
87
- if (cannotStart.test(messageOf(error)))
88
- return undefined;
89
- throw error;
90
- });
91
- if (result === undefined)
96
+ const store = storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() });
97
+ const exists = async (path) => (await claude.fs.stat(path).catch(ifMissing(undefined))) !== undefined;
98
+ if (!(await exists(`${store}/programs/cmod`)))
92
99
  return undefined;
93
- if (result.exitCode !== 0)
94
- throw new Error(`cmod --version ${formatExit(result.exitCode, lastLineOf(result.stderr) ?? '')}`);
95
- return result.stdout.trim().split(/\s+/).at(-1);
100
+ const versions = (await claude.fs.list(`${store}/bin/cmod`).catch(ifMissing([]))).filter((entry) => entry.kind === 'dir' && releaseVersion.test(entry.name)).map((entry) => entry.name);
101
+ const newestFirst = versions.toSorted((one, other) => (isAtLeast(one, other) ? -1 : 1));
102
+ for (const version of newestFirst)
103
+ if (await exists(`${store}/bin/cmod/${version}/cmod`))
104
+ return version;
105
+ return undefined;
96
106
  }
107
+ const releaseVersion = /^\d+\.\d+\.\d+$/;
97
108
  export function createLifecycle(definition, permissionRoutes = () => undefined, options = {}, asksPerson = true) {
98
109
  let router = createRouter();
99
110
  let phase = 'starting';
@@ -103,14 +114,25 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
103
114
  const granted = new Set();
104
115
  let activation;
105
116
  let mod;
117
+ const openSettings = async (tab) => {
118
+ await claude().cmod.call({ to: cmodPluginName, method: partMethods.changed, input: { requests: [{ settings: true, ...(tab === undefined ? {} : { tab }) }], isAsked: true } });
119
+ };
106
120
  let imports;
107
121
  let raise;
108
122
  let failure;
109
123
  let pluginOptions = options;
110
- const installer = createInstaller(definition.name, () => claude());
124
+ const installer = createInstaller(definition.name, () => claude(), {
125
+ show: (open) => {
126
+ if (phase !== 'active')
127
+ runtime?.toaster.toast.dismiss(installId);
128
+ runtime?.toaster.toast.info('Installing', { id: askingId, description: 'Waiting for your answer', duration: Infinity, action: { label: 'Answer', onClick: open } });
129
+ },
130
+ hide: () => runtime?.toaster.toast.dismiss(askingId),
131
+ });
111
132
  let shouldRecord = false;
112
133
  let recording;
113
134
  let missedSessionStart;
135
+ let hasHeardSessionStart = false;
114
136
  let settleStart = () => undefined;
115
137
  const startSettled = new Promise((resolve) => (settleStart = resolve));
116
138
  const claude = () => {
@@ -120,14 +142,15 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
120
142
  };
121
143
  const installing = (description) => runtime?.toaster.toast.loading('Installing', { id: installId, ...(description === undefined ? {} : { description }) });
122
144
  const toastFailure = (shown) => runtime?.toaster.fail(shown);
123
- const fail = (reason, fix, retry) => {
145
+ const fail = (reason, fix, retry, error) => {
124
146
  phase = 'failed';
125
- failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'}${fix === undefined ? '' : ` ${fix}`}`);
147
+ failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'}${fix === undefined ? '' : ` ${fix}`}`, { cause: error });
126
148
  const again = retry === undefined ? {} : { retry: () => ((failure = undefined), stopRetrying(), retry().catch(report)) };
127
- toastFailure({ id: installId, message: reason, ...(fix === undefined ? {} : { description: fix }), ...again });
149
+ toastFailure({ id: installId, message: reason, ...(fix === undefined ? {} : { description: fix }), ...(error === undefined ? {} : { error }), ...again });
128
150
  };
129
151
  let consented;
130
152
  const installAgain = () => install(consented);
153
+ const updateAgain = () => claude().store.delete(cmodUpdatedKey).then(installAgain);
131
154
  let retrying;
132
155
  const retryLater = (next) => {
133
156
  const seconds = installRetrySeconds[Math.min(next.tries, installRetrySeconds.length - 1)];
@@ -139,47 +162,47 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
139
162
  retrying?.timer.cancel();
140
163
  retrying = undefined;
141
164
  };
142
- const checkoutState = async () => {
143
- if (plugin === undefined)
144
- return undefined;
145
- const lines = [];
146
- 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));
147
- return code === 0 ? lines.join('\n') : undefined;
148
- };
149
- const failInstall = async (reason, when) => {
165
+ const failInstall = (reason, when) => {
150
166
  if (when === undefined) {
151
167
  stopRetrying();
152
168
  return fail(reason, undefined, installAgain);
153
169
  }
154
- retryLater({ when, tries: retrying?.when === when ? retrying.tries : 0, checkout: when === 'changed' ? await checkoutState() : undefined, reason });
170
+ retryLater({ when, tries: retrying?.when === when ? retrying.tries : 0, reason });
155
171
  };
156
172
  const retryInBackground = async () => {
157
- const waiting = retrying;
158
- if (phase !== 'failed' || waiting === undefined)
173
+ if (phase !== 'failed' || retrying === undefined)
159
174
  return;
160
175
  if (await recover()) {
161
176
  stopRetrying();
162
177
  return whenActive();
163
178
  }
164
- if (waiting.when === 'changed' && waiting.checkout !== undefined && (await checkoutState()) === waiting.checkout)
165
- return retryLater(waiting);
166
179
  await install(consented, true);
167
180
  await whenActive();
168
181
  };
169
182
  const finish = async () => {
170
183
  await programs?.refresh();
171
184
  phase = 'ready';
185
+ activation ??= new Promise((resolve) => claude().clock.after(0, () => resolve(activate().catch(report))));
172
186
  claude().ui.invalidate('ui.render');
173
187
  };
174
188
  const recordThroughCmod = async () => {
175
189
  if (plugin === undefined || (await cmodVersion(claude())) === undefined)
176
190
  return;
191
+ let failed;
177
192
  const { code, lastError } = await readLines(claude().process.spawn({ argv: ['cmod', 'setup', plugin.root, '--events'] }), (text) => {
178
- if (parseEvent(text).type === 'done')
193
+ const event = parseEvent(text);
194
+ if (event.type === 'done')
179
195
  shouldRecord = false;
196
+ if (event.type === 'failed')
197
+ failed = event.message;
180
198
  });
181
- if (shouldRecord)
182
- throw new Error(`cmod setup ${formatExit(code, lastError)}`);
199
+ if (!shouldRecord) {
200
+ runtime?.toaster.toast.dismiss(recordId);
201
+ return programs?.refresh();
202
+ }
203
+ if (failed !== undefined)
204
+ return void toastFailure({ id: recordId, message: failed, retry: record });
205
+ throw new Error(`cmod setup ${formatExit(code, lastError)}`);
183
206
  };
184
207
  const record = () => {
185
208
  recording ??= recordThroughCmod()
@@ -239,9 +262,9 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
239
262
  if (!isAnswered) {
240
263
  runtime?.toaster.toast.warning(steps.length - index === 1 ? 'Needs one more step' : `Needs ${steps.length - index} more steps`, {
241
264
  id: 'cmod:steps',
242
- description: `Starting with ${step.title}. Finish them in /mods ${definition.name}.`,
265
+ description: `Starting with ${step.title}.`,
243
266
  duration: Infinity,
244
- action: { label: 'Finish', onClick: () => active.settings.open() },
267
+ action: { label: 'Finish', onClick: () => runSteps(active) },
245
268
  });
246
269
  break;
247
270
  }
@@ -255,7 +278,7 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
255
278
  const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), store: plugin.store, steps: plugin.steps, granted, refreshGrants, permissionRoutes, options: pluginOptions }).catch(async (error) => {
256
279
  if (error instanceof MissingOptions && (await askOptions(error)))
257
280
  return undefined;
258
- fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : undefined, begin);
281
+ fail(messageOf(error), error instanceof MissingOptions ? `Retry asks for ${error.keys.length === 1 ? 'it' : 'them'}.` : undefined, begin, error);
259
282
  throw error;
260
283
  });
261
284
  if (active === undefined)
@@ -270,20 +293,8 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
270
293
  void runSteps(active.mod).catch((error) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }));
271
294
  const missed = missedSessionStart;
272
295
  missedSessionStart = undefined;
273
- const replay = (e) => (definition.name === cmodPluginName ? activeRouter.dispatch('classic.SessionStart', e, async () => ({})) : checkedRoute('classic.SessionStart', e, async () => ({})));
274
- const lateAnswer = missed === undefined ? {} : await replay(missed).catch((error) => {
275
- toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error });
276
- return {};
277
- });
278
- const { additionalContext, ...unread } = lateAnswer;
279
- for (const text of additionalContext ?? []) {
280
- const added = await runtime.claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } });
281
- if (added.deny !== undefined)
282
- runtime.claude.ui.log(`${definition.name}: Claude Code refused its SessionStart note: ${added.deny}`, { to: 'debug' });
283
- }
284
- if (Object.keys(unread).length > 0) {
285
- runtime.claude.ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read ${Object.keys(unread).join(', ')} from its SessionStart hooks.`, { to: 'debug' });
286
- }
296
+ if (missed !== undefined)
297
+ await replaySessionStart(missed);
287
298
  const version = plugin.version ?? '';
288
299
  const announced = announcedOf(await runtime.claude.store.get(announcedKey));
289
300
  if (announced?.version === version) {
@@ -292,10 +303,27 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
292
303
  }
293
304
  await runtime.claude.store.set(announcedKey, { version, added });
294
305
  const { toaster } = runtime;
295
- void announcementOf(runtime.claude, plugin, granted, added, announced)
306
+ void announcementOf(runtime.claude, plugin, granted, added, announced, openSettings)
296
307
  .then(({ message, ...options }) => toaster.toast.success(message, { id: installId, duration: announcementMs, ...options }))
297
308
  .catch((error) => toastFailure({ id: installId, message: 'Could not say what it installed', error }));
298
309
  };
310
+ const replaySessionStart = async (e) => {
311
+ hasHeardSessionStart = true;
312
+ const ran = definition.name === cmodPluginName ? router.dispatch('classic.SessionStart', e, async () => ({})) : checkedRoute('classic.SessionStart', e, async () => ({}));
313
+ const answer = await ran.catch((error) => {
314
+ toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error });
315
+ return {};
316
+ });
317
+ const { additionalContext, ...unread } = answer;
318
+ for (const text of additionalContext ?? []) {
319
+ const added = await claude().session.append({ message: { type: 'user', content: [{ type: 'text', text }] } });
320
+ if (added.deny !== undefined)
321
+ claude().ui.log(`${definition.name}: Claude Code refused its SessionStart note: ${added.deny}`, { to: 'debug' });
322
+ }
323
+ if (Object.keys(unread).length > 0) {
324
+ claude().ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read ${Object.keys(unread).join(', ')} from its SessionStart hooks.`, { to: 'debug' });
325
+ }
326
+ };
299
327
  const updatesToShow = async () => {
300
328
  const [home, configHome] = await Promise.all([claude().env.home(), claude().env.configHome()]);
301
329
  const configRoot = configHome ?? `${home ?? ''}/.claude`;
@@ -311,7 +339,6 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
311
339
  claude().ui.log(`${name} waits for consent to run ${event.install}. Run cmod install ${name} in a terminal.`);
312
340
  return;
313
341
  }
314
- runtime?.toaster.toast.info('Installing', { id: installId, description: 'Waiting for your answer', duration: Infinity });
315
342
  const updates = await updatesToShow().catch((error) => {
316
343
  toastFailure({ id: 'cmod:updates', message: 'Could not read whether its marketplace updates automatically', error });
317
344
  return undefined;
@@ -358,7 +385,7 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
358
385
  return askConsent(outcome);
359
386
  return failInstall(outcome?.type === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`, retries);
360
387
  };
361
- const fits = (found) => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}));
388
+ const fits = (found) => found !== undefined && isAtLeast(found, coreVersion);
362
389
  const waitForcmod = (found) => {
363
390
  phase = 'waiting';
364
391
  installing('Waiting for cmod');
@@ -387,9 +414,26 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
387
414
  const longWait = claude().clock.after(cmodWaitMs, () => {
388
415
  timer.cancel();
389
416
  timer = poll(cmodSlowCheckMs);
390
- installing(latest === undefined
391
- ? "Still waiting for cmod to download cmod. See cmod's own line."
392
- : `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.`);
417
+ if (latest === undefined)
418
+ return installing("Still waiting for cmod to download cmod. See cmod's own line.");
419
+ const found = latest;
420
+ const tried = `${found} to ${coreVersion}`;
421
+ return claude()
422
+ .store.get(cmodUpdatedKey)
423
+ .then(async (updated) => {
424
+ if (updated === tried) {
425
+ stop();
426
+ return fail(`${definition.name} needs cmod ${coreVersion} or later, and the newest cmod is ${found}.`, undefined, updateAgain);
427
+ }
428
+ await claude().store.set(cmodUpdatedKey, tried);
429
+ installing(`${definition.name} needs cmod ${coreVersion} or later, and this computer has cmod ${found}, so cmod is updating itself.`);
430
+ const { code, lastError } = await readLines(claude().process.spawn({ argv: ['cmod', 'update', 'cmod'] }), () => undefined);
431
+ if (code === 0)
432
+ return;
433
+ stop();
434
+ fail(`${definition.name} needs cmod ${coreVersion} or later, and updating cmod ${found} ${formatExit(code, lastError)}`, undefined, updateAgain);
435
+ })
436
+ .catch(report);
393
437
  });
394
438
  };
395
439
  const bootstrap = async (root) => {
@@ -427,7 +471,7 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
427
471
  const report = (error) => {
428
472
  failure = error;
429
473
  if (phase !== 'failed')
430
- fail(messageOf(error), undefined, begin);
474
+ fail(messageOf(error), undefined, begin, error);
431
475
  };
432
476
  let consentMs = 0;
433
477
  const stampOf = async (path) => {
@@ -479,8 +523,13 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
479
523
  return;
480
524
  droppedParts.add(item);
481
525
  const declared = plugin?.steps.permissions ?? [];
482
- const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`;
483
- runtime?.toaster.toast.warning(`Answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it`, { id: `dropped:${item}`, description: fix, duration: Infinity });
526
+ const isDeclared = declared.includes(item);
527
+ runtime?.toaster.toast.warning(`Answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it`, {
528
+ id: `dropped:${item}`,
529
+ description: isDeclared ? grantFix : `It needs "permissions": { "${item}": true } in package.json "cmod".`,
530
+ duration: Infinity,
531
+ ...(isDeclared ? { action: { label: 'Settings', onClick: () => openSettings('permissions') } } : {}),
532
+ });
484
533
  },
485
534
  };
486
535
  const checkedRoute = async (event, e, next) => {
@@ -527,7 +576,7 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
527
576
  if (runtime !== undefined)
528
577
  return;
529
578
  programs = locatingPrograms(claudeCalls);
530
- runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name, () => plugin?.version) };
579
+ runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name, () => plugin?.version, () => openSettings('permissions')) };
531
580
  readStarted = read;
532
581
  try {
533
582
  await programs.refresh();
@@ -544,8 +593,10 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
544
593
  return installer.draw(e);
545
594
  if (event === 'ui.close')
546
595
  installer.closed(e.id);
547
- if (event === 'classic.SessionStart')
596
+ if (event === 'classic.SessionStart') {
548
597
  missedSessionStart = undefined;
598
+ hasHeardSessionStart = true;
599
+ }
549
600
  if (event === 'classic.SessionStart' && runtime === undefined) {
550
601
  missedSessionStart = e;
551
602
  return next(e);
@@ -575,6 +626,10 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
575
626
  }
576
627
  if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart')
577
628
  await refreshGrants().catch((error) => claude().ui.log(`${definition.name} keeps its grants from before: ${messageOf(error)}`, { to: 'debug' }));
629
+ if (event === 'classic.UserPromptSubmit' && phase === 'active' && !hasHeardSessionStart) {
630
+ const { session_id, transcript_path, cwd } = e;
631
+ await replaySessionStart({ session_id, transcript_path, cwd, hook_event_name: 'SessionStart', source: 'resume' });
632
+ }
578
633
  if (checksAnswers(event) && phase === 'active' && definition.name !== cmodPluginName)
579
634
  return checkedRoute(event, e, next);
580
635
  const answer = router.dispatch(event, e, next);
@@ -599,7 +654,7 @@ export function createLifecycle(definition, permissionRoutes = () => undefined,
599
654
  async function createMod(definition, runtime) {
600
655
  const { router, toaster } = runtime;
601
656
  const report = (failure) => void toaster.fail(failure);
602
- const claude = answeringOwnCalls(withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {})), router);
657
+ const claude = answeringOwnCalls(withBareRefusals(runtime.claude), router);
603
658
  const added = [];
604
659
  const announce = (feature) => {
605
660
  added.push(feature);
@@ -627,26 +682,6 @@ async function createMod(definition, runtime) {
627
682
  checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
628
683
  report,
629
684
  });
630
- const pages = new Map();
631
- const settingsPages = {
632
- page(page) {
633
- if (pages.has(page.id))
634
- throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`);
635
- if (page.id === changelogPage)
636
- throw new Error(`${definition.name}: the settings page id "${changelogPage}" opens the mod's CHANGELOG.md in /mods. Give the page another id.`);
637
- pages.set(page.id, { title: page.title, handle: area.ui.pane(page) });
638
- },
639
- async open(pageId) {
640
- if (pageId === undefined) {
641
- await claude.cmod.call({ to: cmodPluginName, method: 'openSettings', input: { mod: definition.name } });
642
- return;
643
- }
644
- const page = pages.get(pageId);
645
- if (page === undefined)
646
- throw new Error(`${definition.name} has no settings page "${pageId}". It has: ${[...pages.keys()].join(', ') || 'none'}.`);
647
- await page.handle.open({ focus: true });
648
- },
649
- };
650
685
  let cwd = startCwd;
651
686
  let loadedCwd = startCwd;
652
687
  let staleCwd;
@@ -654,7 +689,7 @@ async function createMod(definition, runtime) {
654
689
  let parentAsked;
655
690
  let hasEarlierPrompts = true;
656
691
  let agentType;
657
- const area = createUi({ name: definition.name, claude, router, toaster, report, announce, mod: () => mod });
692
+ const area = createUi({ name: definition.name, claude, router, report, announce, mod: () => mod });
658
693
  const askParent = async () => {
659
694
  if (definition.name === cmodPluginName)
660
695
  return undefined;
@@ -692,8 +727,7 @@ async function createMod(definition, runtime) {
692
727
  permissionRoutes: runtime.permissionRoutes,
693
728
  });
694
729
  let followed;
695
- let isSetUp = false;
696
- const watches = new Set();
730
+ const watches = createWatches(claude, report);
697
731
  const followFolders = (project, folder) => {
698
732
  if (followed === undefined)
699
733
  return;
@@ -750,23 +784,10 @@ async function createMod(definition, runtime) {
750
784
  const { kind, size, mtimeMs, isLink, realPath } = await checked.fs.stat(expand(path), options);
751
785
  return { isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs, ...(realPath === undefined ? {} : { realPath }) };
752
786
  },
753
- onChange({ path }, listener) {
754
- if (isSetUp)
755
- throw new Error(`${definition.name}: fs.onChange works only in setup, because Claude Code reads the paths it watches when a session starts.`);
756
- const paths = [path].flat().map((each) => resolvedPath(each, places));
757
- const watch = { paths, listener };
758
- watches.add(watch);
759
- if (watches.size === 1) {
760
- hookOn('SessionStart', () => ({ watchPaths: [...new Set([...watches].flatMap((each) => each.paths))] }));
761
- hookOn('FileChanged', ({ file_path, event }) => {
762
- for (const each of watches)
763
- if (each.paths.some((watched) => relativePath(watched, file_path) !== undefined))
764
- void Promise.resolve(each.listener({ path: file_path, type: event })).catch((error) => report({ id: 'fs:onChange', message: 'The fs.onChange listener failed', error }));
765
- return undefined;
766
- });
767
- }
768
- return { dispose: () => void watches.delete(watch) };
769
- },
787
+ onChange: ({ path }, listener) => watches.add([path].flat().map((each) => resolvedPath(each, places)), (change) => {
788
+ bind(imports);
789
+ return listener(change);
790
+ }),
770
791
  },
771
792
  metadata: {
772
793
  read: (path) => files.read(path),
@@ -777,15 +798,23 @@ async function createMod(definition, runtime) {
777
798
  spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
778
799
  },
779
800
  http: { fetch: (url, init) => checked.http.fetch(url, init) },
801
+ clipboard: {
802
+ async write(text) {
803
+ await toaster.copy(text);
804
+ },
805
+ },
780
806
  };
781
807
  const allowed = (permission) => ({ allows: (value) => isCovered(granted, itemOf(permission, value), home) });
782
808
  const flag = (permission) => isCovered(granted, permission, home);
783
- const timer = (ms, subject, start, listener) => {
784
- let isRunning = false;
785
- const started = start(checkedMs(definition.name, `mod.${subject}`, ms), () => {
786
- if (isRunning)
809
+ const timer = (ms, subject, repeats, listener) => {
810
+ let isFiring = false;
811
+ let isRunning = true;
812
+ const fire = () => {
813
+ if (!repeats)
814
+ isRunning = false;
815
+ if (isFiring)
787
816
  return;
788
- isRunning = true;
817
+ isFiring = true;
789
818
  void Promise.resolve()
790
819
  .then(() => {
791
820
  bind(imports);
@@ -793,11 +822,22 @@ async function createMod(definition, runtime) {
793
822
  })
794
823
  .catch((error) => report({ id: `${subject}:${ms}`, message: `The ${subject} ${ms} ms timer failed`, error }))
795
824
  .finally(() => {
796
- isRunning = false;
825
+ isFiring = false;
797
826
  });
798
- });
799
- return { dispose: () => started.cancel() };
827
+ };
828
+ const checked = checkedMs(definition.name, `mod.${subject}`, ms);
829
+ const started = repeats ? claude.clock.every(checked, fire) : claude.clock.after(checked, fire);
830
+ return {
831
+ get isRunning() {
832
+ return isRunning;
833
+ },
834
+ stop() {
835
+ isRunning = false;
836
+ started.cancel();
837
+ },
838
+ };
800
839
  };
840
+ const hotkeys = await createHotkeys({ name: definition.name, declared: definition.hotkeys ?? {}, configRoot, claude, router, answerInput: area.answerInput, report });
801
841
  const mod = {
802
842
  name: definition.name,
803
843
  folder: claude.plugin.root,
@@ -830,12 +870,18 @@ async function createMod(definition, runtime) {
830
870
  files: allowed('files'),
831
871
  config: allowed('config'),
832
872
  },
833
- every: (ms, listener) => timer(ms, 'every', claude.clock.every, listener),
834
- after: (ms, listener) => timer(ms, 'after', claude.clock.after, listener),
873
+ hotkeys,
874
+ every: (ms, listener) => timer(ms, 'every', true, listener),
875
+ after: (ms, listener) => timer(ms, 'after', false, listener),
835
876
  use: (job) => job({ mod, announce, reserveName }),
836
877
  log: (text, options) => claude.ui.log(`${definition.name}: ${text}`, { to: options?.to ?? 'debug' }),
837
- ui: { ...area.ui, notice: (callId, text) => claude.ui.notice(callId, text) },
838
- settings: settingsPages,
878
+ panes: area.panes,
879
+ sidebar: area.sidebar,
880
+ settings: area.settings,
881
+ widgets: area.widgets,
882
+ styles: area.styles,
883
+ toast: toaster.toast,
884
+ ask: (question, options) => claude.ui.ask(question, options),
839
885
  dependencies: dependencyCalls(claude, (call, task) => beforeDeadline(claude, { ms: dependencyCallMs }, call, task)),
840
886
  };
841
887
  bind(imports);
@@ -917,8 +963,14 @@ async function createMod(definition, runtime) {
917
963
  return answer;
918
964
  });
919
965
  router.add('skill.prompt', userSkillHook(claude));
920
- const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input) => run(input, mod)]));
921
- const conversations = definition.name === cmodPluginName ? serveConversations({ claude, router, store: runtime.store, project: () => modState.root, report: (id, message, error) => report({ id, message, error }) }) : undefined;
966
+ router.add('ui.press', (e, next) => area.asked(() => next(e)));
967
+ let api = {};
968
+ const conversations = definition.name === cmodPluginName ? serveConversations({ claude, router, store: runtime.store, project: () => modState.root, report: (id, message, error) => report({ id, message, error }), clear: (id) => toaster.toast.dismiss(id) }) : undefined;
969
+ let pages;
970
+ const reportPart = (id, message, error) => report({ id, message, error });
971
+ const parts = drawnParts({ claude, report: reportPart, press: (to, input) => pages?.press(to, input) });
972
+ const widgets = conversations === undefined ? undefined : serveWidgets({ claude, router, store: runtime.store, report: reportPart, turnClosedBy: (rowId) => conversations.turnClosedBy(rowId), parts });
973
+ pages = widgets === undefined ? undefined : servePages({ claude, router, report: reportPart, widgets, parts });
922
974
  router.add('cmod.call', async (e, next) => {
923
975
  if (e.to !== definition.name)
924
976
  return next(e);
@@ -933,11 +985,14 @@ async function createMod(definition, runtime) {
933
985
  const ask = beforeAskOf(call);
934
986
  if (ask !== undefined)
935
987
  return { value: (await client.answerBefore(ask.session, ask.event, ask.change, ask.isInProject)) ?? null };
936
- if (e.method === settingsPagesMethod)
937
- return { value: [...pages].map(([id, { title }]) => ({ id, title })) };
938
- if (e.method === openPageMethod) {
939
- await settingsPages.open(String(e.input));
940
- return { value: null };
988
+ if (e.method === partMethods.changed && call.caller !== undefined && pages !== undefined) {
989
+ return { value: { isHeld: pages.update(call.caller, e.input) } };
990
+ }
991
+ if (call.caller === cmodPluginName) {
992
+ const turn = e.method === partMethods.draw ? e.input.turn : undefined;
993
+ const answered = area.answer(e.method, turn === undefined ? e.input : { ...e.input, turn: client.turn(turn) });
994
+ if (answered !== undefined)
995
+ return answered;
941
996
  }
942
997
  const served = await conversations?.answer(call);
943
998
  return served ?? answerCall(definition.name, api, e);
@@ -948,14 +1003,14 @@ async function createMod(definition, runtime) {
948
1003
  return ask === undefined ? { deny: `${definition.name}: ${error.message}` } : { value: client.failedBefore(ask.session, ask.event, ask.change, ask.isInProject, error) ?? null };
949
1004
  });
950
1005
  const agents = toolCalls(claude, router);
951
- keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined });
1006
+ keepEngine(mod, { claude: checked, router, toolCalls: agents, report, hasPermissionCheck: () => runtime.permissionRoutes() !== undefined, answerInput: area.answerInput, ...(widgets === undefined ? {} : { widgets }), ...(pages === undefined ? {} : { pages }) });
952
1007
  const names = new Set();
953
1008
  const reserveName = (type, name, taken) => {
954
1009
  const key = `${type}:${name}`;
955
1010
  if (names.has(key))
956
1011
  throw new Error(taken);
957
1012
  names.add(key);
958
- return { dispose: () => void names.delete(key) };
1013
+ return { remove: () => void names.delete(key) };
959
1014
  };
960
1015
  await failsAs('its state did not load', () => modState.load());
961
1016
  await failsAs('its options did not load', () => modOptions.load(root));
@@ -963,24 +1018,18 @@ async function createMod(definition, runtime) {
963
1018
  if (missing.length > 0)
964
1019
  throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key));
965
1020
  await failsAs('the details of its session did not load', () => followCurrent(undefined));
966
- await failsAs('its setup function threw', () => definition.setup(mod));
967
- isSetUp = true;
1021
+ const returned = await failsAs('its setup function threw', () => definition.setup(mod));
1022
+ api = apiOf(definition.name, returned);
968
1023
  await failsAs('its open panes did not load', () => area.restorePanes());
969
- return { mod, added, imports, raise: client.raise };
1024
+ area.started();
1025
+ return { mod, added, imports, raise: client.raise, area };
970
1026
  }
971
- function checkingKeys(claude, toast, keys) {
972
- const bound = Object.entries(keys);
973
- if (bound.length === 0)
974
- return claude;
975
- const addCommand = (command) => {
976
- for (const [key, commandName] of bound) {
977
- if (commandName === command.name && command.immediate !== true) {
978
- 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 });
979
- }
980
- }
981
- return claude.command.register(command);
982
- };
983
- return { ...claude, command: { ...claude.command, register: addCommand } };
1027
+ function apiOf(name, returned) {
1028
+ if (returned === undefined || returned === null)
1029
+ return {};
1030
+ if (typeof returned !== 'object' || !Object.values(returned).every((method) => typeof method === 'function'))
1031
+ throw new Error(`${name}: setup returns its API, an object of methods other mods call, or nothing. It returned ${typeof returned === 'object' ? 'an object with values that are not methods' : typeof returned}.`);
1032
+ return returned;
984
1033
  }
985
1034
  const longestTimerMs = 2_147_483_647;
986
1035
  function checkedMs(name, subject, ms) {
@@ -996,7 +1045,7 @@ function beforeAskOf(call) {
996
1045
  }
997
1046
  async function failsAs(subject, step) {
998
1047
  try {
999
- await step();
1048
+ return await step();
1000
1049
  }
1001
1050
  catch (error) {
1002
1051
  throw new Error(`${subject}: ${messageOf(error)}`, { cause: error });
@@ -1,5 +1,5 @@
1
1
  import { fitsOption, isUnset } from '../options.js';
2
- import { configFolders } from '../records.js';
2
+ import { configFolders, secretFix } from '../records.js';
3
3
  import { listed, messageOf } from '../utils/text.js';
4
4
  export class MissingOptions extends Error {
5
5
  keys;
@@ -50,7 +50,7 @@ export function createOptions({ name, declared, fromClaude, claude, report, chan
50
50
  if (option === undefined)
51
51
  ignore(`sets ${key}, which ${name} does not declare. ${Object.keys(declared).length === 0 ? 'Remove it.' : `Remove it, or use one of: ${Object.keys(declared).join(', ')}.`}`);
52
52
  else if (option.type === 'secret')
53
- ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it, and set it in /config.`);
53
+ ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it. ${secretFix}`);
54
54
  else {
55
55
  const misfit = fitsOption(option, value);
56
56
  if (misfit === undefined)