@cmodjs/core 1.0.0 → 1.5.1

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 (122) hide show
  1. package/docs/commands.md +22 -11
  2. package/docs/dependencies.md +6 -4
  3. package/docs/hooks.md +26 -3
  4. package/docs/index.md +1 -1
  5. package/docs/install-steps.md +5 -3
  6. package/docs/mod.md +29 -3
  7. package/docs/permissions.md +4 -4
  8. package/docs/session.md +126 -63
  9. package/docs/testing.md +7 -5
  10. package/docs/ui.md +9 -1
  11. package/jobs/program.d.ts.map +1 -1
  12. package/jobs/program.js +7 -2
  13. package/mod.d.ts +30 -6
  14. package/mod.d.ts.map +1 -1
  15. package/mod.js +34 -1
  16. package/package.json +1 -1
  17. package/records.d.ts +4 -1
  18. package/records.d.ts.map +1 -1
  19. package/records.js +6 -1
  20. package/register.d.ts.map +1 -1
  21. package/register.js +17 -0
  22. package/runtime/announce.d.ts.map +1 -1
  23. package/runtime/announce.js +2 -0
  24. package/runtime/before.d.ts +32 -0
  25. package/runtime/before.d.ts.map +1 -0
  26. package/runtime/before.js +214 -0
  27. package/runtime/claude.d.ts +2 -1
  28. package/runtime/claude.d.ts.map +1 -1
  29. package/runtime/conversations.d.ts +1 -0
  30. package/runtime/conversations.d.ts.map +1 -1
  31. package/runtime/conversations.js +333 -61
  32. package/runtime/dependencies.js +1 -1
  33. package/runtime/engine.d.ts +2 -6
  34. package/runtime/engine.d.ts.map +1 -1
  35. package/runtime/grants.d.ts.map +1 -1
  36. package/runtime/grants.js +8 -8
  37. package/runtime/hooks.d.ts +1 -1
  38. package/runtime/hooks.d.ts.map +1 -1
  39. package/runtime/hooks.js +19 -4
  40. package/runtime/installer.d.ts +1 -1
  41. package/runtime/installer.d.ts.map +1 -1
  42. package/runtime/installer.js +6 -5
  43. package/runtime/lifecycle.d.ts.map +1 -1
  44. package/runtime/lifecycle.js +130 -59
  45. package/runtime/metadata.d.ts.map +1 -1
  46. package/runtime/metadata.js +5 -4
  47. package/runtime/sessions.d.ts +26 -4
  48. package/runtime/sessions.d.ts.map +1 -1
  49. package/runtime/sessions.js +315 -117
  50. package/runtime/toaster.d.ts +5 -12
  51. package/runtime/toaster.d.ts.map +1 -1
  52. package/runtime/toaster.js +158 -25
  53. package/runtime/ui.d.ts.map +1 -1
  54. package/runtime/ui.js +26 -6
  55. package/session/context/context.d.ts +3 -1
  56. package/session/context/context.d.ts.map +1 -1
  57. package/session/context/turn/message/message.d.ts +1 -0
  58. package/session/context/turn/message/message.d.ts.map +1 -1
  59. package/session/context/turn/message/message.js +42 -1
  60. package/session/context/turn/turn.d.ts +2 -1
  61. package/session/context/turn/turn.d.ts.map +1 -1
  62. package/session/conversation.d.ts +20 -11
  63. package/session/conversation.d.ts.map +1 -1
  64. package/session/conversation.js +50 -27
  65. package/session/goal/goal.d.ts +3 -2
  66. package/session/goal/goal.d.ts.map +1 -1
  67. package/session/goal/task.d.ts +4 -4
  68. package/session/goal/task.d.ts.map +1 -1
  69. package/session/session.d.ts +80 -21
  70. package/session/session.d.ts.map +1 -1
  71. package/session/session.js +1 -0
  72. package/src/jobs/program.ts +7 -2
  73. package/src/mod.ts +48 -5
  74. package/src/records.ts +6 -1
  75. package/src/register.ts +17 -0
  76. package/src/runtime/announce.ts +2 -0
  77. package/src/runtime/before.ts +200 -0
  78. package/src/runtime/claude.ts +3 -0
  79. package/src/runtime/conversations.ts +337 -60
  80. package/src/runtime/dependencies.ts +1 -1
  81. package/src/runtime/engine.ts +2 -1
  82. package/src/runtime/grants.ts +8 -10
  83. package/src/runtime/hooks.ts +20 -4
  84. package/src/runtime/installer.ts +6 -5
  85. package/src/runtime/lifecycle.ts +136 -56
  86. package/src/runtime/metadata.ts +5 -4
  87. package/src/runtime/sessions.ts +316 -111
  88. package/src/runtime/toaster.ts +183 -45
  89. package/src/runtime/ui.ts +23 -4
  90. package/src/session/context/context.ts +3 -1
  91. package/src/session/context/turn/message/message.ts +39 -1
  92. package/src/session/context/turn/turn.ts +2 -1
  93. package/src/session/conversation.ts +56 -31
  94. package/src/session/goal/goal.ts +3 -2
  95. package/src/session/goal/task.ts +4 -4
  96. package/src/session/session.ts +62 -15
  97. package/src/testing/fake-claude.ts +4 -2
  98. package/src/testing/fake-sessions.ts +94 -20
  99. package/src/testing.ts +27 -3
  100. package/src/utils/call-effects.ts +2 -2
  101. package/src/utils/disposable.ts +3 -0
  102. package/src/utils/matcher.ts +1 -1
  103. package/src/utils/paths.ts +6 -3
  104. package/testing/fake-claude.d.ts +1 -0
  105. package/testing/fake-claude.d.ts.map +1 -1
  106. package/testing/fake-claude.js +3 -1
  107. package/testing/fake-sessions.d.ts +8 -1
  108. package/testing/fake-sessions.d.ts.map +1 -1
  109. package/testing/fake-sessions.js +97 -18
  110. package/testing.d.ts +1 -0
  111. package/testing.d.ts.map +1 -1
  112. package/testing.js +23 -2
  113. package/utils/call-effects.d.ts +1 -0
  114. package/utils/call-effects.d.ts.map +1 -1
  115. package/utils/call-effects.js +1 -1
  116. package/utils/disposable.d.ts +4 -0
  117. package/utils/disposable.d.ts.map +1 -0
  118. package/utils/disposable.js +1 -0
  119. package/utils/matcher.js +1 -1
  120. package/utils/paths.d.ts +2 -1
  121. package/utils/paths.d.ts.map +1 -1
  122. package/utils/paths.js +6 -3
@@ -16,6 +16,7 @@ export type RoutedEvent =
16
16
  | 'tool.call'
17
17
  | 'prompt.submit'
18
18
  | 'prompt.context'
19
+ | 'prompt.attachment'
19
20
  | 'command.run'
20
21
  | 'session.measure'
21
22
  | 'skill.prompt'
@@ -38,24 +39,39 @@ type CallFiles = HookInput<'PreToolUse'>['files']
38
39
  const flags: readonly string[] = ['suppressOriginalPrompt', 'reloadSkills', 'retry']
39
40
 
40
41
  const readFields: Record<Hook, readonly string[]> = {
42
+ Setup: ['additionalContext'],
41
43
  SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
42
44
  SessionEnd: [],
43
45
  UserPromptSubmit: ['additionalContext', 'sessionTitle', 'suppressOriginalPrompt'],
46
+ UserPromptExpansion: ['additionalContext', 'suppressOriginalPrompt'],
44
47
  InstructionsLoaded: [],
48
+ ConfigChange: [],
49
+ DirectoryAdded: [],
50
+ PreModelSwitch: ['permissionDecision', 'permissionDecisionReason'],
51
+ PostModelSwitch: ['additionalContext'],
45
52
  PreToolUse: ['additionalContext', 'permissionDecision', 'permissionDecisionReason', 'updatedInput'],
46
53
  PermissionRequest: ['decision'],
47
54
  PermissionDenied: ['retry'],
48
55
  PostToolUse: ['additionalContext', 'updatedToolOutput', 'updatedMCPToolOutput'],
49
56
  PostToolUseFailure: ['additionalContext'],
50
57
  PostToolBatch: ['additionalContext'],
58
+ MessageDisplay: ['displayContent'],
51
59
  SubagentStart: ['additionalContext'],
52
60
  SubagentStop: ['additionalContext'],
61
+ TaskCreated: [],
62
+ TaskCompleted: [],
63
+ TeammateIdle: [],
64
+ Elicitation: [],
65
+ ElicitationResult: [],
53
66
  Notification: [],
54
67
  PreCompact: [],
68
+ PostCompact: [],
55
69
  Stop: ['additionalContext'],
56
70
  StopFailure: [],
57
71
  CwdChanged: [],
58
72
  FileChanged: [],
73
+ WorktreeCreate: ['worktreePath'],
74
+ WorktreeRemove: [],
59
75
  }
60
76
 
61
77
  export function classicHook<E extends Exclude<Hook, 'PreToolUse'>>(name: string, event: E, hook: HookListener<E>, claude: Claude, calls: ToolCalls, report: (failure: Failure) => void): RoutedHook<RoutedEvent> {
@@ -172,9 +188,9 @@ async function preToolUseInput(name: string, envelope: Frozen<Args<'tool.call'>>
172
188
 
173
189
  async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
174
190
  try {
175
- const home = await claude.env.home()
191
+ const [home, configRoot] = await Promise.all([claude.env.home(), claude.env.configHome()])
176
192
  if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
177
- const { shell, reads, writes } = callEffects(use, { cwd, home, fs: claude.fs })
193
+ const { shell, reads, writes } = callEffects(use, { cwd, home, ...(configRoot === undefined ? {} : { configRoot }), fs: claude.fs })
178
194
  const known = (accesses: FileAccess[]) => accesses.map(({ path }) => path).filter((path) => shell === undefined || !dynamicPattern.test(path))
179
195
  const unchecked = (path: string) => (error: unknown) => {
180
196
  if (!isMissing(error)) claude.ui.log(`${name}: the ${use.tool} call reads ${path}, which could not be checked: ${messageOf(error)}`, { to: 'debug' })
@@ -207,10 +223,10 @@ function classicResult(event: Hook, answer: HookAnswer): ClassicFields {
207
223
  if (field === 'hookEventName' || value === undefined || (value === false && flags.includes(field))) continue
208
224
  if (!readFields[event].includes(field)) throw unread(`hookSpecificOutput.${field}`)
209
225
  if (field === 'additionalContext') result['additionalContext'] = [value]
210
- else if (field === 'permissionDecision') {
226
+ else if (field === 'permissionDecision' && event === 'PreToolUse') {
211
227
  const reason = specific.permissionDecisionReason ?? ''
212
228
  result[value as string] = value === 'allow' ? true : reason
213
- } else if (field !== 'permissionDecisionReason') result[field] = value
229
+ } else if (field !== 'permissionDecisionReason' || event !== 'PreToolUse') result[field] = value
214
230
  }
215
231
  return result
216
232
  }
@@ -7,7 +7,7 @@ import { Box, Button, drawWith, Input, Text } from '../ui/elements.js'
7
7
  import { messageOf } from '../utils/text.js'
8
8
  import type { Claude } from './claude.js'
9
9
 
10
- export const installerPaneId = 'cmod-installer'
10
+ export const installerPaneOf = (name: string): string => `installer-${name.replace(/[^A-Za-z0-9_-]/g, '_')}`.slice(0, 64)
11
11
 
12
12
  export type Consent = {
13
13
  readonly install: string
@@ -33,6 +33,7 @@ export type Installer = {
33
33
  }
34
34
 
35
35
  export function createInstaller(name: string, claude: () => Claude): Installer {
36
+ const paneId = installerPaneOf(name)
36
37
  let page: Page | undefined
37
38
  let isOpen = false
38
39
 
@@ -48,7 +49,7 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
48
49
  })
49
50
  if (!isOpen) {
50
51
  isOpen = true
51
- await claude().ui.open({ id: installerPaneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
52
+ await claude().ui.open({ id: paneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
52
53
  }
53
54
  claude().ui.invalidate('ui.render')
54
55
  return answer
@@ -60,12 +61,12 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
60
61
  const bullet = (text: string) => Text({ children: [Text({ color: 'claude', children: '◆ ' }), text] })
61
62
 
62
63
  return {
63
- draws: (e) => e.component === 'Pane' && e.requestId === installerPaneId,
64
+ draws: (e) => e.component === 'Pane' && e.requestId === paneId,
64
65
  draw(e) {
65
66
  return drawWith(claude().ui.resolve(e), () => page?.draw(e.props as Frozen<RenderPropsOf['Pane']>) ?? Text({ dimColor: true, children: `${name} is installing.` }))
66
67
  },
67
68
  closed(id) {
68
- if (id !== installerPaneId) return
69
+ if (id !== paneId) return
69
70
  isOpen = false
70
71
  page?.cancel()
71
72
  },
@@ -164,7 +165,7 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
164
165
  page = undefined
165
166
  if (!isOpen) return
166
167
  isOpen = false
167
- await claude().ui.close({ id: installerPaneId })
168
+ await claude().ui.close({ id: paneId })
168
169
  },
169
170
  }
170
171
  }
@@ -1,16 +1,16 @@
1
- import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement } from 'claude-code'
1
+ import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement, Timer } from 'claude-code'
2
2
  import type { Step } from '../installer.js'
3
3
  import { hooks, PreToolUse, type Allowed, type Disposable, type Hook, type HookInput, type HookListener, type HookOptions, type Imports, type Mod, type ModDefinition, type PaneHandle, type SettingsFile, type Toast } from '../mod.js'
4
4
  import type { Options, OptionValues } from '../options.js'
5
5
  import { changelogPage, consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, recordPath, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
6
- import { ifMissing, relativePath } from '../utils/paths.js'
6
+ import { homePrefix, ifMissing, relativePath } from '../utils/paths.js'
7
7
  import { firstLine, formatExit, listed, messageOf } from '../utils/text.js'
8
8
  import type { Claude } from './claude.js'
9
9
  import { beforeDeadline } from './deadline.js'
10
10
  import { announcedOf, announcementMs, announcementOf } from './announce.js'
11
11
  import { bind } from './bound.js'
12
12
  import { serveConversations } from './conversations.js'
13
- import { answerCall, cmodPluginName, dependencyCalls, notInstalled, withBareRefusals } from './dependencies.js'
13
+ import { answerCall, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
14
14
  import { keepEngine } from './engine.js'
15
15
  import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, type RoutedEvent } from './hooks.js'
16
16
  import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
@@ -22,9 +22,10 @@ import { createOptions, MissingOptions } from './options.js'
22
22
  import { createRouter, type Router, type RouterNext } from './router.js'
23
23
  import { conversationMethods, createSessionsClient } from './sessions.js'
24
24
  import type { SessionChange } from '../session/conversation.js'
25
+ import type { BeforeEvent } from '../session/session.js'
25
26
  import { matches } from '../utils/matcher.js'
26
27
  import { createState } from './state.js'
27
- import { createToaster, showFailure, type Failure, type ShownToast, type Toaster } from './toaster.js'
28
+ import { createToaster, type Failure, type ShownToast, type Toaster } from './toaster.js'
28
29
  import { toolCalls } from './tool-calls.js'
29
30
  import { createUi } from './ui.js'
30
31
  import { Progress } from '../ui/components.js'
@@ -76,6 +77,8 @@ const announcedKey = 'cmod:announced'
76
77
 
77
78
  const installId = 'cmod:install'
78
79
 
80
+ const installRetrySeconds = [2, 4, 8, 16, 32, 64, 128] as const
81
+
79
82
  const cmodCheckMs = 1000
80
83
 
81
84
  const cmodWaitMs = 60_000
@@ -132,7 +135,7 @@ async function readJson(read: ReadFile, path: string): Promise<unknown> {
132
135
  try {
133
136
  return JSON.parse(text)
134
137
  } catch (error) {
135
- throw new Error(`${path} is not JSON (${messageOf(error)}). Fix the file, then run /reload-plugins.`)
138
+ throw new Error(`${path} is not JSON (${messageOf(error)}). Fix it, then press Retry.`)
136
139
  }
137
140
  }
138
141
 
@@ -177,15 +180,58 @@ export function createLifecycle<State extends object, Declared extends Options =
177
180
 
178
181
  const installing = (description?: string | RenderElement) => runtime?.toaster.toast.loading('Installing', { id: installId, ...(description === undefined ? {} : { description }) })
179
182
 
180
- const toastFailure = (shown: Failure) => {
181
- if (runtime !== undefined) showFailure({ name: definition.name, ui: runtime.toaster, claude: runtime.claude }, shown)
182
- }
183
+ const toastFailure = (shown: Failure) => runtime?.toaster.fail(shown)
183
184
 
184
- const fail = (reason: string, fix: string, retry?: () => Promise<void>) => {
185
+ const fail = (reason: string, fix: string | undefined, retry?: () => Promise<void>) => {
185
186
  phase = 'failed'
186
- failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'} ${fix}`)
187
- const action = retry === undefined ? {} : { action: { label: 'Retry', onClick: () => ((failure = undefined), retry().catch(report)) } }
188
- runtime?.toaster.toast.error(reason, { id: installId, description: fix, duration: Infinity, ...action })
187
+ failure ??= new Error(`${definition.name}: ${reason}${/[.!?]$/.test(reason) ? '' : '.'}${fix === undefined ? '' : ` ${fix}`}`)
188
+ const again = retry === undefined ? {} : { retry: () => ((failure = undefined), stopRetrying(), retry().catch(report)) }
189
+ toastFailure({ id: installId, message: reason, ...(fix === undefined ? {} : { description: fix }), ...again })
190
+ }
191
+
192
+ let consented: string | undefined
193
+ const installAgain = () => install(consented)
194
+
195
+ type Retrying = { readonly when: 'changed' | 'later'; readonly tries: number; readonly checkout: string | undefined; readonly reason: string; readonly timer: Timer }
196
+ let retrying: Retrying | undefined
197
+
198
+ const retryLater = (next: Omit<Retrying, 'timer'>) => {
199
+ const seconds = installRetrySeconds[Math.min(next.tries, installRetrySeconds.length - 1)] as number
200
+ retrying?.timer.cancel()
201
+ retrying = { ...next, tries: next.tries + 1, timer: claude().clock.after(seconds * 1000, () => void retryInBackground().catch(report)) }
202
+ fail(next.reason, `Trying again in ${seconds} s.`, installAgain)
203
+ }
204
+
205
+ const stopRetrying = () => {
206
+ retrying?.timer.cancel()
207
+ retrying = undefined
208
+ }
209
+
210
+ const checkoutState = async (): Promise<string | undefined> => {
211
+ if (plugin === undefined) return undefined
212
+ const lines: string[] = []
213
+ 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))
214
+ return code === 0 ? lines.join('\n') : undefined
215
+ }
216
+
217
+ const failInstall = async (reason: string, when: 'changed' | 'later' | undefined) => {
218
+ if (when === undefined) {
219
+ stopRetrying()
220
+ return fail(reason, undefined, installAgain)
221
+ }
222
+ retryLater({ when, tries: retrying?.when === when ? retrying.tries : 0, checkout: when === 'changed' ? await checkoutState() : undefined, reason })
223
+ }
224
+
225
+ const retryInBackground = async () => {
226
+ const waiting = retrying
227
+ if (phase !== 'failed' || waiting === undefined) return
228
+ if (await recover()) {
229
+ stopRetrying()
230
+ return whenActive()
231
+ }
232
+ if (waiting.when === 'changed' && waiting.checkout !== undefined && (await checkoutState()) === waiting.checkout) return retryLater(waiting)
233
+ await install(consented, true)
234
+ await whenActive()
189
235
  }
190
236
 
191
237
  const finish = async () => {
@@ -272,7 +318,7 @@ export function createLifecycle<State extends object, Declared extends Options =
272
318
  const activeRouter = createRouter()
273
319
  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: unknown) => {
274
320
  if (error instanceof MissingOptions && (await askOptions(error))) return undefined
275
- fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : 'Fix it, then run /reload-plugins.')
321
+ fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : undefined, begin)
276
322
  throw error
277
323
  })
278
324
  if (active === undefined) return activate()
@@ -321,38 +367,44 @@ export function createLifecycle<State extends object, Declared extends Options =
321
367
  claude().ui.log(`${name} waits for consent to run ${event.install}. Run cmod install ${name} in a terminal.`)
322
368
  return
323
369
  }
324
- installing('Waiting for your answer')
370
+ runtime?.toaster.toast.info('Installing', { id: installId, description: 'Waiting for your answer', duration: Infinity })
325
371
  const updates = await updatesToShow().catch((error: unknown) => {
326
372
  toastFailure({ id: 'cmod:updates', message: 'Could not read whether its marketplace updates automatically', error })
327
373
  return undefined
328
374
  })
329
- if (await installer.consent({ ...event, updates })) return install(event.sha256)
375
+ const isAccepted = await installer.consent({ ...event, updates })
330
376
  await installer.close()
377
+ if (isAccepted) {
378
+ consented = event.sha256
379
+ return install(consented)
380
+ }
331
381
  phase = 'declined'
332
- runtime?.toaster.toast('Not installed', { id: installId, description: `Run cmod install ${name} to install it.` })
382
+ runtime?.toaster.toast('Not installed', { id: installId, description: 'Install it whenever you like.', duration: Infinity, action: { label: 'Install', onClick: () => install().catch(report) } })
333
383
  }
334
384
 
335
- const install = async (consent?: string): Promise<void> => {
385
+ const install = async (consent?: string, isQuiet = false): Promise<void> => {
336
386
  if (plugin === undefined) return
337
387
  if (plugin.name === cmodPluginName) return bootstrap(plugin.root)
338
388
  phase = 'installing'
339
389
  const found = await cmodVersion(claude())
340
390
  if (!fits(found)) return waitForcmod(found)
341
- installing()
391
+ if (!isQuiet) installing()
342
392
  let outcome: RunnerEvent | undefined
393
+ let retries: 'changed' | 'later' | undefined
343
394
  const argv = ['cmod', 'setup', plugin.root, '--events', ...(consent === undefined ? [] : ['--consent', consent])]
344
395
  const { code, lastError } = await readLines(claude().process.spawn({ argv }), (text) => {
345
396
  const event = parseEvent(text)
346
- if (event.type === 'progress') installing(progressOf(event))
397
+ if (event.type === 'progress' && !isQuiet) installing(progressOf(event))
398
+ if (event.type === 'retries') retries = event.when
347
399
  if (event.type === 'done' || event.type === 'failed' || event.type === 'needs-consent') outcome = event
348
400
  })
349
401
  if (outcome?.type === 'done') {
402
+ stopRetrying()
350
403
  for (const item of plugin.steps.permissions ?? []) granted.add(item)
351
404
  return finish()
352
405
  }
353
406
  if (outcome?.type === 'needs-consent') return askConsent(outcome)
354
- const reason = outcome?.type === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`
355
- fail(reason, `Fix the cause, then retry, or run cmod install ${definition.name} in a terminal.`, install)
407
+ return failInstall(outcome?.type === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`, retries)
356
408
  }
357
409
 
358
410
  const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}))
@@ -397,12 +449,29 @@ export function createLifecycle<State extends object, Declared extends Options =
397
449
  if (event.type === 'progress') installing(progressOf(event))
398
450
  })
399
451
  if (code === 0) return finish()
400
- fail(`bootstrap ${formatExit(code, lastError)}`, `Run ./setup/bootstrap.sh in ${root} to see the whole log.`, () => bootstrap(root))
452
+ fail(`bootstrap ${formatExit(code, lastError)}`, undefined, () => bootstrap(root))
453
+ }
454
+
455
+ const begin = async (): Promise<void> => {
456
+ if (readStarted === undefined) return
457
+ const started = await readStarted(claude())
458
+ plugin = started
459
+ for (const item of started.granted) granted.add(item)
460
+ consentMs = await stampOf(consentPath(started.store))
461
+ recordMs = await stampOf(recordPath(started.store, started.name))
462
+ if (started.isInstalled) {
463
+ shouldRecord = started.shouldRecord
464
+ if (shouldRecord) record()
465
+ activation = undefined
466
+ return activate()
467
+ }
468
+ if (started.version === undefined) return fail('no version to install', 'Its author adds "version" to .claude-plugin/plugin.json.')
469
+ void install().catch(report)
401
470
  }
402
471
 
403
472
  const report = (error: unknown) => {
404
473
  failure = error
405
- if (phase !== 'failed') fail(messageOf(error), `Run cmod install ${definition.name} in a terminal to see the whole log.`)
474
+ if (phase !== 'failed') fail(messageOf(error), undefined, begin)
406
475
  }
407
476
 
408
477
  let consentMs = 0
@@ -412,18 +481,19 @@ export function createLifecycle<State extends object, Declared extends Options =
412
481
  }
413
482
  let recordMs = 0
414
483
  let readStarted: ((claude: Claude) => Promise<Plugin>) | undefined
415
- const recover = async () => {
416
- if (plugin === undefined || readStarted === undefined || (phase !== 'failed' && phase !== 'declined')) return
484
+ const recover = async (): Promise<boolean> => {
485
+ if (plugin === undefined || readStarted === undefined || (phase !== 'failed' && phase !== 'declined')) return false
417
486
  const modifiedMs = await stampOf(recordPath(plugin.store, plugin.name))
418
- if (modifiedMs === recordMs) return
487
+ if (modifiedMs === recordMs) return false
419
488
  recordMs = modifiedMs
420
489
  const current = await readStarted(claude())
421
- if (!current.isInstalled) return
490
+ if (!current.isInstalled) return false
422
491
  plugin = current
423
492
  failure = undefined
424
493
  activation = undefined
425
494
  await refreshGrants()
426
495
  await finish()
496
+ return true
427
497
  }
428
498
 
429
499
  const refreshGrants = async () => {
@@ -493,26 +563,11 @@ export function createLifecycle<State extends object, Declared extends Options =
493
563
  async start(claudeCalls, read) {
494
564
  if (runtime !== undefined) return
495
565
  programs = locatingPrograms(claudeCalls)
496
- runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name) }
566
+ runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name, () => plugin?.version) }
567
+ readStarted = read
497
568
  try {
498
569
  await programs.refresh()
499
- const started = await read(claudeCalls).catch((error: unknown) => {
500
- report(error)
501
- return undefined
502
- })
503
- if (started === undefined) return
504
- plugin = started
505
- readStarted = read
506
- for (const item of started.granted) granted.add(item)
507
- consentMs = await stampOf(consentPath(started.store))
508
- recordMs = await stampOf(recordPath(started.store, started.name))
509
- if (started.isInstalled) {
510
- shouldRecord = started.shouldRecord
511
- if (shouldRecord) record()
512
- return await activate().catch(report)
513
- }
514
- if (started.version === undefined) return fail('no version to install', 'Add "version" to .claude-plugin/plugin.json, then run /reload-plugins.')
515
- void install().catch(report)
570
+ await begin().catch(report)
516
571
  } finally {
517
572
  settleStart()
518
573
  }
@@ -529,11 +584,12 @@ export function createLifecycle<State extends object, Declared extends Options =
529
584
  if (shouldRecord && event === 'classic.UserPromptSubmit') record()
530
585
  if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart') await recover().catch((error: unknown) => claude().ui.log(`${definition.name} stays stopped: ${messageOf(error)}`, { to: 'debug' }))
531
586
  const isCallToThisMod = event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name
587
+ if (event === 'cmod.call' && !isCallToThisMod) return next(e)
532
588
  await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
533
589
  if (event === 'classic.SessionStart' && phase !== 'active') missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
534
590
  if (isCallToThisMod && phase !== 'active') {
535
591
  const isStopped = phase === 'declined' || phase === 'failed'
536
- return { deny: isStopped ? notInstalled(definition.name) : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
592
+ return { deny: isStopped ? `${definition.name} is not set up. Its toast says why.` : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
537
593
  }
538
594
  const call = e as Frozen<Args<'cmod.call'>>
539
595
  if (event === 'cmod.call' && call.to === definition.name && mod !== undefined) {
@@ -541,18 +597,18 @@ export function createLifecycle<State extends object, Declared extends Options =
541
597
  if (call.method === finishStepsMethod) return (await runSteps(mod), { value: null }) as EventResult<N>
542
598
  }
543
599
  if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart') await refreshGrants().catch((error: unknown) => claude().ui.log(`${definition.name} keeps its grants from before: ${messageOf(error)}`, { to: 'debug' }))
544
- if (checksAnswers(event) && phase === 'active') return checkedRoute(event, e, next)
600
+ if (checksAnswers(event) && phase === 'active' && definition.name !== cmodPluginName) return checkedRoute(event, e, next)
545
601
  const answer = router.dispatch(event, e, next)
546
602
  const render = e as Frozen<Args<'ui.render'>>
547
603
  if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined) return answer
548
- return runtime.toaster.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.props) as EventResult<N>
604
+ return runtime.toaster.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.props, render.requestId) as EventResult<N>
549
605
  },
550
606
  }
551
607
  }
552
608
 
553
609
  async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
554
610
  const { router, toaster } = runtime
555
- const report = (failure: Failure) => void showFailure({ name: definition.name, ui: toaster, claude: runtime.claude }, failure)
611
+ const report = (failure: Failure) => void toaster.fail(failure)
556
612
  const claude = withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {}))
557
613
  const added: string[] = []
558
614
  const hooksAdded: Hook[] = []
@@ -605,6 +661,7 @@ async function createMod<State extends object, Declared extends Options>(definit
605
661
  let forkedFrom: string | undefined
606
662
  let parentAsked: Promise<string | undefined> | undefined
607
663
  let hasEarlierPrompts = true
664
+ let agentType: string | undefined
608
665
  const area = createUi<State>({ name: definition.name, claude, router, toaster, report, announce, mod: () => mod })
609
666
  const askParent = async (): Promise<string | undefined> => {
610
667
  if (definition.name === cmodPluginName) return undefined
@@ -624,7 +681,7 @@ async function createMod<State extends object, Declared extends Options>(definit
624
681
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
625
682
  const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
626
683
  const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
627
- const expand = (path: string) => (path === '~' || path.startsWith('~/') ? resolvedPath(path, places) : path)
684
+ const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
628
685
  const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
629
686
  read: () => checked.settings.read({ source }),
630
687
  async update(values) {
@@ -632,7 +689,7 @@ async function createMod<State extends object, Declared extends Options>(definit
632
689
  await claude.cmod.call({ to: cmodPluginName, method: 'updateSettings', input: { mod: definition.name, source, project: modState.root, values } })
633
690
  },
634
691
  })
635
- const client = createSessionsClient({ name: definition.name, claude: checked, report: (id, message, error) => report({ id, message, error }) })
692
+ const client = createSessionsClient({ name: definition.name, claude: checked, report: (id, message, error) => report({ id, message, error }), isGranted: (permission) => flag(permission) })
636
693
  let followed: Parameters<typeof client.follow>[0] | undefined
637
694
  const followFolders = (project: string, folder: string) => {
638
695
  if (followed === undefined) return
@@ -641,7 +698,22 @@ async function createMod<State extends object, Declared extends Options>(definit
641
698
  }
642
699
  const imports: Imports = {
643
700
  sessions: client.sessions,
644
- settings: { user: settingsFile('user'), project: settingsFile('project'), local: settingsFile('local') },
701
+ settings: {
702
+ list: async () =>
703
+ (await checked.config.list()).map(({ key, label, description, kind, value, options, provider, isLocked }) => ({
704
+ key,
705
+ label,
706
+ kind,
707
+ value,
708
+ isLocked,
709
+ ...(description === undefined ? {} : { description }),
710
+ ...(options === undefined ? {} : { options }),
711
+ ...(provider.plugin === 'engine' ? {} : { plugin: provider.plugin }),
712
+ })),
713
+ user: settingsFile('user'),
714
+ project: settingsFile('project'),
715
+ local: settingsFile('local'),
716
+ },
645
717
  model: { complete: (completion, options) => checked.model.complete(completion, options) },
646
718
  fs: {
647
719
  read: (path) => checked.fs.read(expand(path)),
@@ -765,10 +837,11 @@ async function createMod<State extends object, Declared extends Options>(definit
765
837
  }
766
838
  const followCurrent = async (title: string | undefined) => {
767
839
  const [id, model, usage] = await Promise.all([claude.session.id(), claude.session.model(), claude.session.usage()])
768
- followed = { id, title: title ?? '', project: modState.root, cwd, model, startedAt: new Date(usage.startedAt).toISOString(), ...(forkedFrom === undefined ? {} : { forkedFrom }) }
840
+ followed = { id, title: title ?? '', project: modState.root, cwd, model, startedAt: new Date(usage.startedAt).toISOString(), ...(forkedFrom === undefined ? {} : { forkedFrom }), ...(agentType === undefined ? {} : { agentType }) }
769
841
  client.follow(followed)
770
842
  }
771
843
  router.add('classic.SessionStart', async (e, next) => {
844
+ agentType = e.agent_id === undefined ? e.agent_type : agentType
772
845
  const previous = followed?.id
773
846
  if (e.source === 'fork' && previous !== undefined && previous !== e.session_id) forkedFrom = previous
774
847
  else if (e.session_id !== previous) {
@@ -797,7 +870,10 @@ async function createMod<State extends object, Declared extends Options>(definit
797
870
  }
798
871
  router.add('command.run', async (e, next) => {
799
872
  const answer = await next(e)
800
- if (e.command === 'cd') await followSession(true)
873
+ if (e.command === 'cd') {
874
+ await followSession(true)
875
+ conversations?.moved()
876
+ }
801
877
  return answer
802
878
  })
803
879
  router.add('skill.prompt', userSkillHook(claude))
@@ -808,10 +884,14 @@ async function createMod<State extends object, Declared extends Options>(definit
808
884
  const call = e as typeof e & { readonly caller?: string }
809
885
  if (e.method === conversationMethods.changed && call.caller === cmodPluginName) {
810
886
  if (!flag('conversation')) return { value: null }
811
- const { session: changedSession, change } = e.input as { readonly session: string; readonly change: SessionChange }
812
- client.changed(changedSession, change)
887
+ const { session: changedSession, change, isInProject } = e.input as { readonly session: string; readonly change: SessionChange; readonly isInProject?: boolean }
888
+ client.changed(changedSession, change, isInProject !== false)
813
889
  return { value: null }
814
890
  }
891
+ if (e.method === conversationMethods.before && call.caller === cmodPluginName) {
892
+ const { session: asking, event, change, isInProject } = e.input as { readonly session: string; readonly event: BeforeEvent; readonly change: SessionChange; readonly isInProject?: boolean }
893
+ return { value: (await client.answerBefore(asking, event, change, isInProject !== false)) ?? null }
894
+ }
815
895
  if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
816
896
  if (e.method === openPageMethod) {
817
897
  await settingsPages.open(String(e.input))
@@ -1,6 +1,6 @@
1
1
  import type { FileMatch, Metadata } from '../mod.js'
2
2
  import { formatOf, MetadataError, readMetadata, sidecarSuffix, writeMetadata, type MetadataFormat } from '../utils/metadata.js'
3
- import { ifMissing, isMissing } from '../utils/paths.js'
3
+ import { expandHome, homePrefix, ifMissing, isMissing } from '../utils/paths.js'
4
4
  import { messageOf } from '../utils/text.js'
5
5
  import { picomatch, resolve } from '../vendor.js'
6
6
  import type { Claude } from './claude.js'
@@ -29,9 +29,10 @@ const ownKey = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/
29
29
  export function resolvedPath(path: string, places: Places): string {
30
30
  const configured = /^~\/\.claude(?=\/|$)/.exec(path)
31
31
  if (configured !== null && places.configRoot !== undefined) return resolve(`${places.configRoot}${path.slice(configured[0].length)}`)
32
- if (path === '~' || path.startsWith('~/')) {
33
- if (places.home === undefined) throw new Error(`${path} starts with ~, and HOME is not set. Start Claude Code with HOME set.`)
34
- return resolve(`${places.home}${path.slice(1)}`)
32
+ const prefix = homePrefix.exec(path)?.[0]
33
+ if (prefix !== undefined) {
34
+ if (places.home === undefined) throw new Error(`${path} starts with ${prefix}, and HOME is not set. Start Claude Code with HOME set.`)
35
+ return resolve(expandHome(path, places.home, places.configRoot))
35
36
  }
36
37
  return path.startsWith('/') ? resolve(path) : resolve(places.projectRoot(), path)
37
38
  }