@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.
- package/docs/commands.md +22 -11
- package/docs/dependencies.md +6 -4
- package/docs/hooks.md +26 -3
- package/docs/index.md +1 -1
- package/docs/install-steps.md +5 -3
- package/docs/mod.md +29 -3
- package/docs/permissions.md +4 -4
- package/docs/session.md +126 -63
- package/docs/testing.md +7 -5
- package/docs/ui.md +9 -1
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +7 -2
- package/mod.d.ts +30 -6
- package/mod.d.ts.map +1 -1
- package/mod.js +34 -1
- package/package.json +1 -1
- package/records.d.ts +4 -1
- package/records.d.ts.map +1 -1
- package/records.js +6 -1
- package/register.d.ts.map +1 -1
- package/register.js +17 -0
- package/runtime/announce.d.ts.map +1 -1
- package/runtime/announce.js +2 -0
- package/runtime/before.d.ts +32 -0
- package/runtime/before.d.ts.map +1 -0
- package/runtime/before.js +214 -0
- package/runtime/claude.d.ts +2 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +1 -0
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +333 -61
- package/runtime/dependencies.js +1 -1
- package/runtime/engine.d.ts +2 -6
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +8 -8
- package/runtime/hooks.d.ts +1 -1
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +19 -4
- package/runtime/installer.d.ts +1 -1
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +6 -5
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +130 -59
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +5 -4
- package/runtime/sessions.d.ts +26 -4
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +315 -117
- package/runtime/toaster.d.ts +5 -12
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +158 -25
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +26 -6
- package/session/context/context.d.ts +3 -1
- package/session/context/context.d.ts.map +1 -1
- package/session/context/turn/message/message.d.ts +1 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +42 -1
- package/session/context/turn/turn.d.ts +2 -1
- package/session/context/turn/turn.d.ts.map +1 -1
- package/session/conversation.d.ts +20 -11
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +50 -27
- package/session/goal/goal.d.ts +3 -2
- package/session/goal/goal.d.ts.map +1 -1
- package/session/goal/task.d.ts +4 -4
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +80 -21
- package/session/session.d.ts.map +1 -1
- package/session/session.js +1 -0
- package/src/jobs/program.ts +7 -2
- package/src/mod.ts +48 -5
- package/src/records.ts +6 -1
- package/src/register.ts +17 -0
- package/src/runtime/announce.ts +2 -0
- package/src/runtime/before.ts +200 -0
- package/src/runtime/claude.ts +3 -0
- package/src/runtime/conversations.ts +337 -60
- package/src/runtime/dependencies.ts +1 -1
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/grants.ts +8 -10
- package/src/runtime/hooks.ts +20 -4
- package/src/runtime/installer.ts +6 -5
- package/src/runtime/lifecycle.ts +136 -56
- package/src/runtime/metadata.ts +5 -4
- package/src/runtime/sessions.ts +316 -111
- package/src/runtime/toaster.ts +183 -45
- package/src/runtime/ui.ts +23 -4
- package/src/session/context/context.ts +3 -1
- package/src/session/context/turn/message/message.ts +39 -1
- package/src/session/context/turn/turn.ts +2 -1
- package/src/session/conversation.ts +56 -31
- package/src/session/goal/goal.ts +3 -2
- package/src/session/goal/task.ts +4 -4
- package/src/session/session.ts +62 -15
- package/src/testing/fake-claude.ts +4 -2
- package/src/testing/fake-sessions.ts +94 -20
- package/src/testing.ts +27 -3
- package/src/utils/call-effects.ts +2 -2
- package/src/utils/disposable.ts +3 -0
- package/src/utils/matcher.ts +1 -1
- package/src/utils/paths.ts +6 -3
- package/testing/fake-claude.d.ts +1 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +3 -1
- package/testing/fake-sessions.d.ts +8 -1
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +97 -18
- package/testing.d.ts +1 -0
- package/testing.d.ts.map +1 -1
- package/testing.js +23 -2
- package/utils/call-effects.d.ts +1 -0
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +1 -1
- package/utils/disposable.d.ts +4 -0
- package/utils/disposable.d.ts.map +1 -0
- package/utils/disposable.js +1 -0
- package/utils/matcher.js +1 -1
- package/utils/paths.d.ts +2 -1
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +6 -3
package/src/runtime/hooks.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtime/installer.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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 ===
|
|
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 !==
|
|
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:
|
|
168
|
+
await claude().ui.close({ id: paneId })
|
|
168
169
|
},
|
|
169
170
|
}
|
|
170
171
|
}
|
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
|
188
|
-
|
|
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.` :
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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)}`,
|
|
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),
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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) => (
|
|
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: {
|
|
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')
|
|
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))
|
package/src/runtime/metadata.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
}
|