@cmodjs/core 0.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +61 -10
- package/docs/dependencies.md +16 -14
- package/docs/hooks.md +64 -29
- package/docs/index.md +29 -25
- package/docs/install-steps.md +13 -9
- package/docs/jobs.md +59 -111
- package/docs/mod.md +227 -175
- package/docs/options.md +2 -2
- package/docs/permissions.md +27 -16
- package/docs/session.md +311 -0
- package/docs/state.md +11 -7
- package/docs/testing.md +68 -23
- package/docs/ui.md +116 -76
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +5 -2
- package/jobs/context.d.ts +7 -8
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +23 -73
- package/jobs/permissions/decide-permission.d.ts +5 -3
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +6 -4
- package/jobs/permissions/find-project-scope.d.ts +1 -1
- package/jobs/permissions/find-project-scope.d.ts.map +1 -1
- package/jobs/permissions/find-project-scope.js +8 -11
- package/jobs/permissions/match-target.d.ts +2 -2
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions.d.ts +1 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +6 -4
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +14 -4
- package/jobs/prompt.d.ts +4 -2
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +16 -15
- package/jobs/schedule.d.ts +2 -2
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +9 -9
- package/jobs/slash-command.d.ts +4 -4
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +14 -9
- package/jobs/status-line.d.ts +1 -11
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +8 -12
- package/jobs/tool.d.ts +2 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +15 -9
- package/mod.d.ts +178 -75
- package/mod.d.ts.map +1 -1
- package/mod.js +127 -3
- package/options.d.ts +2 -2
- package/options.js +19 -19
- package/package.json +3 -3
- package/records.d.ts +13 -8
- package/records.d.ts.map +1 -1
- package/records.js +33 -17
- package/register.d.ts.map +1 -1
- package/register.js +34 -5
- package/runtime/announce.d.ts +18 -0
- package/runtime/announce.d.ts.map +1 -0
- package/runtime/announce.js +68 -0
- package/runtime/before.d.ts +32 -0
- package/runtime/before.d.ts.map +1 -0
- package/runtime/before.js +214 -0
- package/runtime/bound.d.ts +4 -0
- package/runtime/bound.d.ts.map +1 -0
- package/runtime/bound.js +9 -0
- package/runtime/claude.d.ts +4 -2
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +17 -0
- package/runtime/conversations.d.ts.map +1 -0
- package/runtime/conversations.js +563 -0
- package/runtime/deadline.d.ts +0 -1
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +1 -1
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +10 -1
- package/runtime/engine.d.ts +13 -0
- package/runtime/engine.d.ts.map +1 -0
- package/runtime/engine.js +10 -0
- package/runtime/grants.d.ts +4 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +68 -31
- package/runtime/hooks.d.ts +5 -4
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +29 -9
- package/runtime/installer.d.ts +1 -1
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +17 -14
- package/runtime/lifecycle.d.ts +3 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +414 -212
- package/runtime/metadata.d.ts +3 -1
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +25 -14
- package/runtime/options.d.ts +3 -1
- package/runtime/options.d.ts.map +1 -1
- package/runtime/options.js +4 -4
- package/runtime/programs.d.ts +8 -0
- package/runtime/programs.d.ts.map +1 -1
- package/runtime/programs.js +39 -1
- package/runtime/router.d.ts +2 -1
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +11 -3
- package/runtime/sessions.d.ts +58 -0
- package/runtime/sessions.d.ts.map +1 -0
- package/runtime/sessions.js +433 -0
- package/runtime/state.d.ts +6 -2
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +46 -13
- package/runtime/toaster.d.ts +24 -0
- package/runtime/toaster.d.ts.map +1 -0
- package/runtime/toaster.js +286 -0
- package/runtime/ui.d.ts +6 -17
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +62 -118
- package/session/context/context.d.ts +12 -0
- package/session/context/context.d.ts.map +1 -0
- package/session/context/context.js +2 -0
- package/session/context/turn/message/document.d.ts +7 -0
- package/session/context/turn/message/document.d.ts.map +1 -0
- package/session/context/turn/message/document.js +1 -0
- package/session/context/turn/message/message.d.ts +55 -0
- package/session/context/turn/message/message.d.ts.map +1 -0
- package/session/context/turn/message/message.js +178 -0
- package/session/context/turn/message/note.d.ts +8 -0
- package/session/context/turn/message/note.d.ts.map +1 -0
- package/session/context/turn/message/note.js +1 -0
- package/session/context/turn/message/notice.d.ts +6 -0
- package/session/context/turn/message/notice.d.ts.map +1 -0
- package/session/context/turn/message/notice.js +1 -0
- package/session/context/turn/message/prompt.d.ts +8 -0
- package/session/context/turn/message/prompt.d.ts.map +1 -0
- package/session/context/turn/message/prompt.js +1 -0
- package/session/context/turn/message/reply.d.ts +6 -0
- package/session/context/turn/message/reply.d.ts.map +1 -0
- package/session/context/turn/message/reply.js +1 -0
- package/session/context/turn/message/sender.d.ts +14 -0
- package/session/context/turn/message/sender.d.ts.map +1 -0
- package/session/context/turn/message/sender.js +30 -0
- package/session/context/turn/message/skill.d.ts +7 -0
- package/session/context/turn/message/skill.d.ts.map +1 -0
- package/session/context/turn/message/skill.js +1 -0
- package/session/context/turn/message/tool-call.d.ts +9 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -0
- package/session/context/turn/message/tool-call.js +1 -0
- package/session/context/turn/message/tool-result.d.ts +8 -0
- package/session/context/turn/message/tool-result.d.ts.map +1 -0
- package/session/context/turn/message/tool-result.js +1 -0
- package/session/context/turn/turn.d.ts +13 -0
- package/session/context/turn/turn.d.ts.map +1 -0
- package/session/context/turn/turn.js +2 -0
- package/session/conversation.d.ts +73 -0
- package/session/conversation.d.ts.map +1 -0
- package/session/conversation.js +146 -0
- package/session/goal/goal.d.ts +11 -0
- package/session/goal/goal.d.ts.map +1 -0
- package/session/goal/goal.js +1 -0
- package/session/goal/task.d.ts +16 -0
- package/session/goal/task.d.ts.map +1 -0
- package/session/goal/task.js +3 -0
- package/session/session.d.ts +158 -0
- package/session/session.d.ts.map +1 -0
- package/session/session.js +2 -0
- package/src/jobs/check.ts +7 -4
- package/src/jobs/context.ts +24 -82
- package/src/jobs/permissions/decide-permission.ts +10 -8
- package/src/jobs/permissions/find-project-scope.ts +8 -14
- package/src/jobs/permissions/match-target.ts +3 -3
- package/src/jobs/permissions.ts +8 -6
- package/src/jobs/program.ts +13 -4
- package/src/jobs/prompt.ts +21 -24
- package/src/jobs/schedule.ts +14 -14
- package/src/jobs/slash-command.ts +20 -14
- package/src/jobs/status-line.ts +9 -21
- package/src/jobs/tool.ts +17 -11
- package/src/mod.ts +252 -97
- package/src/options.ts +22 -22
- package/src/records.ts +40 -23
- package/src/register.ts +34 -5
- package/src/runtime/announce.ts +76 -0
- package/src/runtime/before.ts +200 -0
- package/src/runtime/bound.ts +12 -0
- package/src/runtime/claude.ts +7 -2
- package/src/runtime/conversations.ts +579 -0
- package/src/runtime/deadline.ts +2 -2
- package/src/runtime/dependencies.ts +13 -1
- package/src/runtime/engine.ts +23 -0
- package/src/runtime/grants.ts +69 -29
- package/src/runtime/hooks.ts +39 -14
- package/src/runtime/installer.ts +16 -14
- package/src/runtime/lifecycle.ts +413 -201
- package/src/runtime/metadata.ts +37 -14
- package/src/runtime/options.ts +19 -4
- package/src/runtime/programs.ts +40 -1
- package/src/runtime/router.ts +11 -4
- package/src/runtime/sessions.ts +447 -0
- package/src/runtime/state.ts +50 -14
- package/src/runtime/toaster.ts +334 -0
- package/src/runtime/ui.ts +65 -143
- package/src/session/context/context.ts +14 -0
- package/src/session/context/turn/message/document.ts +8 -0
- package/src/session/context/turn/message/message.ts +203 -0
- package/src/session/context/turn/message/note.ts +9 -0
- package/src/session/context/turn/message/notice.ts +7 -0
- package/src/session/context/turn/message/prompt.ts +9 -0
- package/src/session/context/turn/message/reply.ts +7 -0
- package/src/session/context/turn/message/sender.ts +38 -0
- package/src/session/context/turn/message/skill.ts +8 -0
- package/src/session/context/turn/message/tool-call.ts +10 -0
- package/src/session/context/turn/message/tool-result.ts +9 -0
- package/src/session/context/turn/turn.ts +15 -0
- package/src/session/conversation.ts +194 -0
- package/src/session/goal/goal.ts +10 -0
- package/src/session/goal/task.ts +19 -0
- package/src/session/session.ts +141 -0
- package/src/testing/fake-claude.ts +25 -17
- package/src/testing/fake-elements.ts +3 -2
- package/src/testing/fake-files.ts +1 -1
- package/src/testing/fake-sessions.ts +277 -0
- package/src/testing.ts +138 -35
- package/src/ui/components.ts +3 -3
- package/src/ui/elements.ts +22 -8
- package/src/ui/markdown.ts +22 -26
- package/src/ui/places.ts +49 -0
- package/src/utils/call-effects.ts +5 -5
- package/src/utils/changelog.ts +32 -0
- package/src/utils/disposable.ts +3 -0
- package/src/utils/matcher.ts +37 -0
- package/src/utils/metadata.ts +17 -15
- package/src/utils/parse-shell.ts +9 -8
- package/src/utils/paths.ts +21 -4
- package/src/utils/text.ts +4 -0
- package/testing/fake-claude.d.ts +5 -1
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +21 -12
- package/testing/fake-elements.js +4 -2
- package/testing/fake-files.js +1 -1
- package/testing/fake-sessions.d.ts +81 -0
- package/testing/fake-sessions.d.ts.map +1 -0
- package/testing/fake-sessions.js +249 -0
- package/testing.d.ts +34 -13
- package/testing.d.ts.map +1 -1
- package/testing.js +115 -23
- package/ui/components.d.ts +2 -3
- package/ui/components.d.ts.map +1 -1
- package/ui/components.js +3 -3
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +19 -9
- package/ui/markdown.d.ts +35 -33
- package/ui/markdown.d.ts.map +1 -1
- package/ui/markdown.js +14 -16
- package/ui/places.d.ts +43 -0
- package/ui/places.d.ts.map +1 -0
- package/ui/places.js +31 -0
- package/utils/call-effects.d.ts +2 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +3 -3
- package/utils/changelog.d.ts +9 -0
- package/utils/changelog.d.ts.map +1 -0
- package/utils/changelog.js +29 -0
- package/utils/disposable.d.ts +4 -0
- package/utils/disposable.d.ts.map +1 -0
- package/utils/disposable.js +1 -0
- package/utils/matcher.d.ts +13 -0
- package/utils/matcher.d.ts.map +1 -0
- package/utils/matcher.js +32 -0
- package/utils/metadata.d.ts +3 -3
- package/utils/metadata.js +18 -14
- package/utils/parse-shell.js +9 -7
- package/utils/paths.d.ts +4 -1
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +19 -4
- package/utils/text.d.ts +1 -0
- package/utils/text.d.ts.map +1 -1
- package/utils/text.js +3 -0
- package/src/ui/slots.ts +0 -36
- package/ui/slots.d.ts +0 -208
- package/ui/slots.d.ts.map +0 -1
- package/ui/slots.js +0 -19
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -1,23 +1,35 @@
|
|
|
1
|
-
import type { Args, EventResult, Frozen, HookBudget,
|
|
2
|
-
import type {
|
|
1
|
+
import type { Args, EventResult, Frozen, HookBudget, PluginOptions, RenderElement, Timer } from 'claude-code'
|
|
2
|
+
import type { Step } from '../installer.js'
|
|
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'
|
|
3
4
|
import type { Options, OptionValues } from '../options.js'
|
|
4
|
-
import { consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
|
|
5
|
-
import { relativePath } from '../utils/paths.js'
|
|
6
|
-
import { formatExit, listed, messageOf } from '../utils/text.js'
|
|
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 { homePrefix, ifMissing, relativePath } from '../utils/paths.js'
|
|
7
|
+
import { firstLine, formatExit, listed, messageOf } from '../utils/text.js'
|
|
7
8
|
import type { Claude } from './claude.js'
|
|
8
9
|
import { beforeDeadline } from './deadline.js'
|
|
9
|
-
import {
|
|
10
|
+
import { announcedOf, announcementMs, announcementOf } from './announce.js'
|
|
11
|
+
import { bind } from './bound.js'
|
|
12
|
+
import { serveConversations } from './conversations.js'
|
|
13
|
+
import { answerCall, cmodPluginName, dependencyCalls, withBareRefusals } from './dependencies.js'
|
|
14
|
+
import { keepEngine } from './engine.js'
|
|
10
15
|
import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, type RoutedEvent } from './hooks.js'
|
|
11
|
-
import { checkedAnswer, checkingGrants, checksAnswers, checkWrite, isCovered, itemOf, passedDown, type AnswerCheck, type Grants } from './grants.js'
|
|
12
|
-
import { locatingPrograms, type Programs } from './programs.js'
|
|
13
|
-
import { createModFiles } from './metadata.js'
|
|
16
|
+
import { checkedAnswer, checkingGrants, checkSettings, checksAnswers, checkWrite, isCovered, itemOf, passedDown, programAddresses, type AnswerCheck, type Grants } from './grants.js'
|
|
17
|
+
import { cmodError, lastLineOf, locatingPrograms, readLines, type Programs } from './programs.js'
|
|
18
|
+
import { createModFiles, resolvedPath, type Places } from './metadata.js'
|
|
14
19
|
import { fitsOption } from '../options.js'
|
|
15
20
|
import { createInstaller } from './installer.js'
|
|
16
21
|
import { createOptions, MissingOptions } from './options.js'
|
|
17
22
|
import { createRouter, type Router, type RouterNext } from './router.js'
|
|
23
|
+
import { conversationMethods, createSessionsClient } from './sessions.js'
|
|
24
|
+
import type { SessionChange } from '../session/conversation.js'
|
|
25
|
+
import type { BeforeEvent } from '../session/session.js'
|
|
26
|
+
import { matches } from '../utils/matcher.js'
|
|
18
27
|
import { createState } from './state.js'
|
|
28
|
+
import { createToaster, type Failure, type ShownToast, type Toaster } from './toaster.js'
|
|
19
29
|
import { toolCalls } from './tool-calls.js'
|
|
20
|
-
import {
|
|
30
|
+
import { createUi } from './ui.js'
|
|
31
|
+
import { Progress } from '../ui/components.js'
|
|
32
|
+
import { Box, Text } from '../ui/elements.js'
|
|
21
33
|
|
|
22
34
|
export type Plugin = {
|
|
23
35
|
readonly name: string
|
|
@@ -36,6 +48,7 @@ export type Lifecycle<State extends object> = {
|
|
|
36
48
|
readonly phase: Phase
|
|
37
49
|
readonly mod: Mod<State> | undefined
|
|
38
50
|
readonly failure: unknown
|
|
51
|
+
readonly toasts: readonly ShownToast[]
|
|
39
52
|
start(claude: Claude, read: (claude: Claude) => Promise<Plugin>): Promise<void>
|
|
40
53
|
route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
41
54
|
}
|
|
@@ -43,8 +56,9 @@ export type Lifecycle<State extends object> = {
|
|
|
43
56
|
type ModRuntime = {
|
|
44
57
|
readonly claude: Claude
|
|
45
58
|
readonly router: Router
|
|
46
|
-
readonly
|
|
59
|
+
readonly toaster: Toaster
|
|
47
60
|
readonly dataFolder: string
|
|
61
|
+
readonly store: string
|
|
48
62
|
readonly steps: Steps
|
|
49
63
|
readonly granted: ReadonlySet<string>
|
|
50
64
|
readonly refreshGrants: () => Promise<void>
|
|
@@ -55,33 +69,32 @@ type ModRuntime = {
|
|
|
55
69
|
type ActiveMod<State extends object, Declared extends Options> = {
|
|
56
70
|
readonly mod: Mod<State, Declared>
|
|
57
71
|
readonly added: readonly string[]
|
|
72
|
+
readonly imports: Imports
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
const cmodPluginName = 'cmod'
|
|
61
75
|
|
|
62
76
|
const announcedKey = 'cmod:announced'
|
|
63
77
|
|
|
78
|
+
const installId = 'cmod:install'
|
|
79
|
+
|
|
80
|
+
const installRetrySeconds = [2, 4, 8, 16, 32, 64, 128] as const
|
|
81
|
+
|
|
64
82
|
const cmodCheckMs = 1000
|
|
65
83
|
|
|
66
84
|
const cmodWaitMs = 60_000
|
|
67
85
|
|
|
68
|
-
const
|
|
86
|
+
const startHoldMs = (10_000 satisfies HookBudget['ms']) - 1_000
|
|
69
87
|
|
|
70
88
|
const dependencyCallMs = 30_000
|
|
71
89
|
|
|
72
90
|
const cannotStart = /failed to start: /
|
|
73
91
|
|
|
74
|
-
const missingPath = /(?:^|: )(?:ENOENT|ENOTDIR)\b/
|
|
75
|
-
|
|
76
92
|
const shellTools: readonly string[] = ['Bash', 'PowerShell']
|
|
77
93
|
|
|
78
94
|
export async function readPlugin(claude: Claude): Promise<Plugin> {
|
|
79
95
|
const { name, root } = claude.plugin
|
|
80
96
|
const read: ReadFile = async (path) => {
|
|
81
|
-
const stat = await claude.fs.stat(path).catch((
|
|
82
|
-
if (missingPath.test(messageOf(error))) return undefined
|
|
83
|
-
throw error
|
|
84
|
-
})
|
|
97
|
+
const stat = await claude.fs.stat(path).catch(ifMissing(undefined))
|
|
85
98
|
return stat?.kind === 'file' ? claude.fs.read(path) : undefined
|
|
86
99
|
}
|
|
87
100
|
const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`)) as { version?: unknown } | undefined
|
|
@@ -122,7 +135,7 @@ async function readJson(read: ReadFile, path: string): Promise<unknown> {
|
|
|
122
135
|
try {
|
|
123
136
|
return JSON.parse(text)
|
|
124
137
|
} catch (error) {
|
|
125
|
-
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.`)
|
|
126
139
|
}
|
|
127
140
|
}
|
|
128
141
|
|
|
@@ -144,13 +157,13 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
144
157
|
): Lifecycle<State> {
|
|
145
158
|
let router: Router = createRouter()
|
|
146
159
|
let phase: Phase = 'starting'
|
|
147
|
-
let runtime: Pick<ModRuntime, 'claude' | '
|
|
160
|
+
let runtime: Pick<ModRuntime, 'claude' | 'toaster'> | undefined
|
|
148
161
|
let programs: Programs | undefined
|
|
149
162
|
let plugin: Plugin | undefined
|
|
150
163
|
const granted = new Set<string>()
|
|
151
|
-
let line: ProgressLine | undefined
|
|
152
164
|
let activation: Promise<void> | undefined
|
|
153
165
|
let mod: Mod<State> | undefined
|
|
166
|
+
let imports: Imports | undefined
|
|
154
167
|
let failure: unknown
|
|
155
168
|
let pluginOptions = options
|
|
156
169
|
const installer = createInstaller(definition.name, () => claude())
|
|
@@ -165,20 +178,60 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
165
178
|
return runtime.claude
|
|
166
179
|
}
|
|
167
180
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
181
|
+
const installing = (description?: string | RenderElement) => runtime?.toaster.toast.loading('Installing', { id: installId, ...(description === undefined ? {} : { description }) })
|
|
182
|
+
|
|
183
|
+
const toastFailure = (shown: Failure) => runtime?.toaster.fail(shown)
|
|
184
|
+
|
|
185
|
+
const fail = (reason: string, fix: string | undefined, retry?: () => Promise<void>) => {
|
|
186
|
+
phase = 'failed'
|
|
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 })
|
|
171
190
|
}
|
|
172
191
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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)
|
|
176
203
|
}
|
|
177
204
|
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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()
|
|
182
235
|
}
|
|
183
236
|
|
|
184
237
|
const finish = async () => {
|
|
@@ -190,7 +243,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
190
243
|
const recordThroughCmod = async () => {
|
|
191
244
|
if (plugin === undefined || (await cmodVersion(claude())) === undefined) return
|
|
192
245
|
const { code, lastError } = await readLines(claude().process.spawn({ argv: ['cmod', 'setup', plugin.root, '--events'] }), (text) => {
|
|
193
|
-
if (parseEvent(text).
|
|
246
|
+
if (parseEvent(text).type === 'done') shouldRecord = false
|
|
194
247
|
})
|
|
195
248
|
if (shouldRecord) throw new Error(`cmod setup ${formatExit(code, lastError)}`)
|
|
196
249
|
}
|
|
@@ -206,11 +259,11 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
206
259
|
const saveOption = async (key: string, text: string): Promise<string | undefined> => {
|
|
207
260
|
const option = (definition.options as Options | undefined)?.[key]
|
|
208
261
|
if (option === undefined || plugin === undefined) return `${definition.name} has no option ${key}.`
|
|
209
|
-
const value = option.
|
|
210
|
-
const misfit = option.
|
|
262
|
+
const value = option.type === 'number' ? Number(text) : option.type === 'toggle' ? text.trim() === 'true' : option.type === 'list' ? text.split(',').map((item) => item.trim()).filter((item) => item !== '') : text
|
|
263
|
+
const misfit = option.type === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value)
|
|
211
264
|
if (misfit !== undefined) return `${option.title} ${misfit}.`
|
|
212
265
|
const saved = await claude().process.run(['cmod', 'option', plugin.name, key, Array.isArray(value) ? value.join(',') : String(value)])
|
|
213
|
-
return saved.exitCode === 0 ? undefined :
|
|
266
|
+
return saved.exitCode === 0 ? undefined : cmodError(saved.stderr, 'option') || `cmod option ${formatExit(saved.exitCode, '')}.`
|
|
214
267
|
}
|
|
215
268
|
|
|
216
269
|
const askOptions = async (missing: MissingOptions): Promise<boolean> => {
|
|
@@ -227,19 +280,33 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
227
280
|
return true
|
|
228
281
|
}
|
|
229
282
|
|
|
283
|
+
const isStepDone = async (active: Mod<State>, step: Step<State>) => {
|
|
284
|
+
try {
|
|
285
|
+
return await step.isDone(active)
|
|
286
|
+
} catch (error) {
|
|
287
|
+
toastFailure({ id: `step:${step.title}`, message: `The ${step.title} step's isDone failed`, error })
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
230
292
|
const pendingSteps = async (active: Mod<State>) => {
|
|
231
293
|
const pending: string[] = []
|
|
232
|
-
for (const step of definition.installer ?? []) if (!(await
|
|
294
|
+
for (const step of definition.installer ?? []) if (!(await isStepDone(active, step))) pending.push(step.title)
|
|
233
295
|
return pending
|
|
234
296
|
}
|
|
235
297
|
|
|
236
298
|
const runSteps = async (active: Mod<State>) => {
|
|
237
299
|
const steps = asksPerson ? (definition.installer ?? []) : []
|
|
238
300
|
for (const [index, step] of steps.entries()) {
|
|
239
|
-
if (await
|
|
301
|
+
if (await isStepDone(active, step)) continue
|
|
240
302
|
const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length))
|
|
241
303
|
if (!isAnswered) {
|
|
242
|
-
|
|
304
|
+
runtime?.toaster.toast.warning(steps.length - index === 1 ? 'Needs one more step' : `Needs ${steps.length - index} more steps`, {
|
|
305
|
+
id: 'cmod:steps',
|
|
306
|
+
description: `Starting with ${step.title}. Finish them in /mods ${definition.name}.`,
|
|
307
|
+
duration: Infinity,
|
|
308
|
+
action: { label: 'Finish', onClick: () => active.settings.open() },
|
|
309
|
+
})
|
|
243
310
|
break
|
|
244
311
|
}
|
|
245
312
|
}
|
|
@@ -249,86 +316,102 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
249
316
|
const activate = async (): Promise<void> => {
|
|
250
317
|
if (runtime === undefined || plugin === undefined) return
|
|
251
318
|
const activeRouter = createRouter()
|
|
252
|
-
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error: unknown) => {
|
|
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) => {
|
|
253
320
|
if (error instanceof MissingOptions && (await askOptions(error))) return undefined
|
|
254
|
-
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)
|
|
255
322
|
throw error
|
|
256
323
|
})
|
|
257
324
|
if (active === undefined) return activate()
|
|
258
325
|
const { added } = active
|
|
259
326
|
mod = active.mod
|
|
327
|
+
imports = active.imports
|
|
260
328
|
router = activeRouter
|
|
261
329
|
phase = 'active'
|
|
262
330
|
runtime.claude.ui.invalidate('ui.render')
|
|
263
|
-
|
|
264
|
-
void runSteps(active.mod).catch((error: unknown) => claude().ui.log(`${definition.name}: its installer stopped: ${messageOf(error)}`))
|
|
331
|
+
void runSteps(active.mod).catch((error: unknown) => toastFailure({ id: 'cmod:steps', message: 'Its installer stopped', error }))
|
|
265
332
|
const missed = missedSessionStart
|
|
266
333
|
missedSessionStart = undefined
|
|
267
334
|
const lateAnswer = missed === undefined ? {} : await activeRouter.dispatch('classic.SessionStart', missed, async () => ({})).catch((error: unknown) => {
|
|
268
|
-
|
|
335
|
+
toastFailure({ id: 'classic.SessionStart', message: 'The SessionStart hook failed', error })
|
|
269
336
|
return {}
|
|
270
337
|
})
|
|
271
338
|
if (Object.keys(lateAnswer).length > 0) {
|
|
272
339
|
runtime.claude.ui.log(`${definition.name} started after Claude Code's SessionStart, so Claude Code did not read the answer of its SessionStart hooks.`, { to: 'debug' })
|
|
273
340
|
}
|
|
274
341
|
const version = plugin.version ?? ''
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
342
|
+
const announced = announcedOf(await runtime.claude.store.get(announcedKey))
|
|
343
|
+
if (announced?.version === version) {
|
|
344
|
+
runtime.toaster.toast.dismiss(installId)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
await runtime.claude.store.set(announcedKey, { version, added })
|
|
348
|
+
const { toaster } = runtime
|
|
349
|
+
void announcementOf(runtime.claude, plugin, granted, added, announced)
|
|
350
|
+
.then(({ message, ...options }) => toaster.toast.success(message, { id: installId, duration: announcementMs, ...options }))
|
|
351
|
+
.catch((error: unknown) => toastFailure({ id: installId, message: 'Could not say what it installed', error }))
|
|
279
352
|
}
|
|
280
353
|
|
|
281
354
|
const updatesToShow = async () => {
|
|
282
355
|
const [home, configHome] = await Promise.all([claude().env.home(), claude().env.configHome()])
|
|
283
356
|
const configRoot = configHome ?? `${home ?? ''}/.claude`
|
|
284
|
-
const known = await claude()
|
|
357
|
+
const known = await claude()
|
|
358
|
+
.fs.read(`${configRoot}/plugins/known_marketplaces.json`)
|
|
359
|
+
.then((text): unknown => JSON.parse(text))
|
|
360
|
+
.catch(ifMissing(undefined))
|
|
285
361
|
return updatesToTurnOn(marketplaceOf(claude().plugin.root, claude().plugin.name), await claude().settings.read({ source: 'user' }), known)
|
|
286
362
|
}
|
|
287
363
|
|
|
288
|
-
const askConsent = async (event: Extract<RunnerEvent, {
|
|
364
|
+
const askConsent = async (event: Extract<RunnerEvent, { type: 'needs-consent' }>) => {
|
|
289
365
|
const name = definition.name
|
|
290
366
|
if ((await claude().session.surfaces()).length === 0) {
|
|
291
|
-
showLine().wait(`Waiting for consent: run cmod install ${name}`)
|
|
292
367
|
claude().ui.log(`${name} waits for consent to run ${event.install}. Run cmod install ${name} in a terminal.`)
|
|
293
368
|
return
|
|
294
369
|
}
|
|
295
|
-
|
|
296
|
-
|
|
370
|
+
installing('Waiting for your answer')
|
|
371
|
+
const updates = await updatesToShow().catch((error: unknown) => {
|
|
372
|
+
toastFailure({ id: 'cmod:updates', message: 'Could not read whether its marketplace updates automatically', error })
|
|
373
|
+
return undefined
|
|
374
|
+
})
|
|
375
|
+
const isAccepted = await installer.consent({ ...event, updates })
|
|
297
376
|
await installer.close()
|
|
377
|
+
if (isAccepted) {
|
|
378
|
+
consented = event.sha256
|
|
379
|
+
return install(consented)
|
|
380
|
+
}
|
|
298
381
|
phase = 'declined'
|
|
299
|
-
|
|
300
|
-
claude().ui.log(`${name} is not installed. 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) } })
|
|
301
383
|
}
|
|
302
384
|
|
|
303
|
-
const install = async (consent?: string): Promise<void> => {
|
|
385
|
+
const install = async (consent?: string, isQuiet = false): Promise<void> => {
|
|
304
386
|
if (plugin === undefined) return
|
|
305
387
|
if (plugin.name === cmodPluginName) return bootstrap(plugin.root)
|
|
306
388
|
phase = 'installing'
|
|
307
389
|
const found = await cmodVersion(claude())
|
|
308
390
|
if (!fits(found)) return waitForcmod(found)
|
|
309
|
-
|
|
310
|
-
const progress = showLine()
|
|
391
|
+
if (!isQuiet) installing()
|
|
311
392
|
let outcome: RunnerEvent | undefined
|
|
393
|
+
let retries: 'changed' | 'later' | undefined
|
|
312
394
|
const argv = ['cmod', 'setup', plugin.root, '--events', ...(consent === undefined ? [] : ['--consent', consent])]
|
|
313
395
|
const { code, lastError } = await readLines(claude().process.spawn({ argv }), (text) => {
|
|
314
396
|
const event = parseEvent(text)
|
|
315
|
-
if (event.
|
|
316
|
-
if (event.
|
|
397
|
+
if (event.type === 'progress' && !isQuiet) installing(progressOf(event))
|
|
398
|
+
if (event.type === 'retries') retries = event.when
|
|
399
|
+
if (event.type === 'done' || event.type === 'failed' || event.type === 'needs-consent') outcome = event
|
|
317
400
|
})
|
|
318
|
-
if (outcome?.
|
|
401
|
+
if (outcome?.type === 'done') {
|
|
402
|
+
stopRetrying()
|
|
319
403
|
for (const item of plugin.steps.permissions ?? []) granted.add(item)
|
|
320
404
|
return finish()
|
|
321
405
|
}
|
|
322
|
-
if (outcome?.
|
|
323
|
-
|
|
324
|
-
fail(reason, `Fix the cause, then run: cmod install ${definition.name}`)
|
|
406
|
+
if (outcome?.type === 'needs-consent') return askConsent(outcome)
|
|
407
|
+
return failInstall(outcome?.type === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`, retries)
|
|
325
408
|
}
|
|
326
409
|
|
|
327
410
|
const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}))
|
|
328
411
|
|
|
329
412
|
const waitForcmod = (found: string | undefined) => {
|
|
330
413
|
phase = 'waiting'
|
|
331
|
-
|
|
414
|
+
installing('Waiting for cmod')
|
|
332
415
|
let latest = found
|
|
333
416
|
let check: Promise<void> | undefined
|
|
334
417
|
const stop = () => {
|
|
@@ -350,7 +433,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
350
433
|
})
|
|
351
434
|
})
|
|
352
435
|
const longWait = claude().clock.after(cmodWaitMs, () =>
|
|
353
|
-
|
|
436
|
+
installing(
|
|
354
437
|
latest === undefined
|
|
355
438
|
? "Still waiting for cmod to download cmod. See cmod's own line."
|
|
356
439
|
: `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.`,
|
|
@@ -359,30 +442,64 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
359
442
|
}
|
|
360
443
|
|
|
361
444
|
const bootstrap = async (root: string) => {
|
|
362
|
-
|
|
445
|
+
installing()
|
|
363
446
|
phase = 'installing'
|
|
364
447
|
const { code, lastError } = await readLines(claude().process.spawn({ argv: ['sh', '-c', './setup/bootstrap.sh'], cwd: root }), (text) => {
|
|
365
448
|
const event = parseEvent(text)
|
|
366
|
-
if (event.
|
|
449
|
+
if (event.type === 'progress') installing(progressOf(event))
|
|
367
450
|
})
|
|
368
451
|
if (code === 0) return finish()
|
|
369
|
-
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)
|
|
370
470
|
}
|
|
371
471
|
|
|
372
472
|
const report = (error: unknown) => {
|
|
373
473
|
failure = error
|
|
374
|
-
if (phase !== 'failed') fail(messageOf(error),
|
|
474
|
+
if (phase !== 'failed') fail(messageOf(error), undefined, begin)
|
|
375
475
|
}
|
|
376
476
|
|
|
377
477
|
let consentMs = 0
|
|
378
|
-
const
|
|
379
|
-
const stat = await claude().fs.stat(path).catch((
|
|
478
|
+
const stampOf = async (path: string) => {
|
|
479
|
+
const stat = await claude().fs.stat(path).catch(ifMissing(undefined))
|
|
380
480
|
return stat?.kind === 'file' ? stat.mtimeMs : 0
|
|
381
481
|
}
|
|
482
|
+
let recordMs = 0
|
|
483
|
+
let readStarted: ((claude: Claude) => Promise<Plugin>) | undefined
|
|
484
|
+
const recover = async (): Promise<boolean> => {
|
|
485
|
+
if (plugin === undefined || readStarted === undefined || (phase !== 'failed' && phase !== 'declined')) return false
|
|
486
|
+
const modifiedMs = await stampOf(recordPath(plugin.store, plugin.name))
|
|
487
|
+
if (modifiedMs === recordMs) return false
|
|
488
|
+
recordMs = modifiedMs
|
|
489
|
+
const current = await readStarted(claude())
|
|
490
|
+
if (!current.isInstalled) return false
|
|
491
|
+
plugin = current
|
|
492
|
+
failure = undefined
|
|
493
|
+
activation = undefined
|
|
494
|
+
await refreshGrants()
|
|
495
|
+
await finish()
|
|
496
|
+
return true
|
|
497
|
+
}
|
|
498
|
+
|
|
382
499
|
const refreshGrants = async () => {
|
|
383
500
|
if (plugin === undefined || plugin.name === cmodPluginName) return
|
|
384
501
|
const path = consentPath(plugin.store)
|
|
385
|
-
const modifiedMs = await
|
|
502
|
+
const modifiedMs = await stampOf(path)
|
|
386
503
|
if (modifiedMs === consentMs) return
|
|
387
504
|
consentMs = modifiedMs
|
|
388
505
|
const approved = modifiedMs === 0 ? [] : (parseConsent(JSON.parse(await claude().fs.read(path)), path)[plugin.name] ?? [])
|
|
@@ -403,7 +520,7 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
403
520
|
droppedParts.add(item)
|
|
404
521
|
const declared = plugin?.steps.permissions ?? []
|
|
405
522
|
const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`
|
|
406
|
-
|
|
523
|
+
runtime?.toaster.toast.warning(`Answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it`, { id: `dropped:${item}`, description: fix, duration: Infinity })
|
|
407
524
|
},
|
|
408
525
|
}
|
|
409
526
|
|
|
@@ -419,14 +536,16 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
419
536
|
if (activation !== undefined && phase !== 'active') await activation
|
|
420
537
|
}
|
|
421
538
|
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
void
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
539
|
+
const holdUntilStarted = (): Promise<void> =>
|
|
540
|
+
runtime === undefined
|
|
541
|
+
? startSettled.then(whenActive)
|
|
542
|
+
: new Promise<void>((resolve) => {
|
|
543
|
+
const limit = claude().clock.after(startHoldMs, resolve)
|
|
544
|
+
void startSettled.then(whenActive).then(() => {
|
|
545
|
+
limit.cancel()
|
|
546
|
+
resolve()
|
|
547
|
+
})
|
|
548
|
+
})
|
|
430
549
|
|
|
431
550
|
return {
|
|
432
551
|
get phase() {
|
|
@@ -438,32 +557,23 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
438
557
|
get failure() {
|
|
439
558
|
return failure
|
|
440
559
|
},
|
|
560
|
+
get toasts() {
|
|
561
|
+
return runtime?.toaster.shown ?? []
|
|
562
|
+
},
|
|
441
563
|
async start(claudeCalls, read) {
|
|
442
564
|
if (runtime !== undefined) return
|
|
443
565
|
programs = locatingPrograms(claudeCalls)
|
|
444
|
-
runtime = { claude: programs.claude,
|
|
566
|
+
runtime = { claude: programs.claude, toaster: createToaster(claudeCalls, definition.name, () => plugin?.version) }
|
|
567
|
+
readStarted = read
|
|
445
568
|
try {
|
|
446
569
|
await programs.refresh()
|
|
447
|
-
|
|
448
|
-
report(error)
|
|
449
|
-
return undefined
|
|
450
|
-
})
|
|
451
|
-
if (started === undefined) return
|
|
452
|
-
plugin = started
|
|
453
|
-
for (const item of started.granted) granted.add(item)
|
|
454
|
-
consentMs = await consentStamp(consentPath(started.store))
|
|
455
|
-
if (started.isInstalled) {
|
|
456
|
-
shouldRecord = started.shouldRecord
|
|
457
|
-
if (shouldRecord) record()
|
|
458
|
-
return await activate().catch(report)
|
|
459
|
-
}
|
|
460
|
-
if (started.version === undefined) return fail('no version to install', 'Add "version" to .claude-plugin/plugin.json, then run /reload-plugins.')
|
|
461
|
-
void install().catch(report)
|
|
570
|
+
await begin().catch(report)
|
|
462
571
|
} finally {
|
|
463
572
|
settleStart()
|
|
464
573
|
}
|
|
465
574
|
},
|
|
466
575
|
async route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
576
|
+
if (imports !== undefined) bind(imports)
|
|
467
577
|
if (event === 'ui.render' && installer.draws(e as Frozen<Args<'ui.render'>>)) return installer.draw(e as Frozen<Args<'ui.render'>>) as EventResult<N>
|
|
468
578
|
if (event === 'ui.close') installer.closed((e as Frozen<Args<'ui.close'>>).id)
|
|
469
579
|
if (event === 'classic.SessionStart') missedSessionStart = undefined
|
|
@@ -472,11 +582,14 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
472
582
|
return next(e)
|
|
473
583
|
}
|
|
474
584
|
if (shouldRecord && event === 'classic.UserPromptSubmit') record()
|
|
475
|
-
|
|
585
|
+
if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart') await recover().catch((error: unknown) => claude().ui.log(`${definition.name} stays stopped: ${messageOf(error)}`, { to: 'debug' }))
|
|
586
|
+
const isCallToThisMod = event === 'cmod.call' && (e as Frozen<Args<'cmod.call'>>).to === definition.name
|
|
587
|
+
if (event === 'cmod.call' && !isCallToThisMod) return next(e)
|
|
588
|
+
await (event === 'classic.SessionStart' || isCallToThisMod ? holdUntilStarted() : whenActive())
|
|
476
589
|
if (event === 'classic.SessionStart' && phase !== 'active') missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
|
|
477
|
-
if (
|
|
590
|
+
if (isCallToThisMod && phase !== 'active') {
|
|
478
591
|
const isStopped = phase === 'declined' || phase === 'failed'
|
|
479
|
-
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>
|
|
480
593
|
}
|
|
481
594
|
const call = e as Frozen<Args<'cmod.call'>>
|
|
482
595
|
if (event === 'cmod.call' && call.to === definition.name && mod !== undefined) {
|
|
@@ -484,20 +597,21 @@ export function createLifecycle<State extends object, Declared extends Options =
|
|
|
484
597
|
if (call.method === finishStepsMethod) return (await runSteps(mod), { value: null }) as EventResult<N>
|
|
485
598
|
}
|
|
486
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' }))
|
|
487
|
-
if (checksAnswers(event) && phase === 'active') return checkedRoute(event, e, next)
|
|
600
|
+
if (checksAnswers(event) && phase === 'active' && definition.name !== cmodPluginName) return checkedRoute(event, e, next)
|
|
488
601
|
const answer = router.dispatch(event, e, next)
|
|
489
602
|
const render = e as Frozen<Args<'ui.render'>>
|
|
490
|
-
if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined
|
|
491
|
-
return runtime.
|
|
603
|
+
if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined) return answer
|
|
604
|
+
return runtime.toaster.draw((await answer) as EventResult<'ui.render'>, runtime.claude.ui.resolve(render), render.props, render.requestId) as EventResult<N>
|
|
492
605
|
},
|
|
493
606
|
}
|
|
494
607
|
}
|
|
495
608
|
|
|
496
609
|
async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
|
|
497
|
-
const { router,
|
|
498
|
-
const
|
|
610
|
+
const { router, toaster } = runtime
|
|
611
|
+
const report = (failure: Failure) => void toaster.fail(failure)
|
|
612
|
+
const claude = withBareRefusals(checkingKeys(runtime.claude, toaster.toast, runtime.steps.keys ?? {}))
|
|
499
613
|
const added: string[] = []
|
|
500
|
-
const
|
|
614
|
+
const hooksAdded: Hook[] = []
|
|
501
615
|
const announce = (feature: string) => {
|
|
502
616
|
added.push(feature)
|
|
503
617
|
}
|
|
@@ -513,18 +627,22 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
513
627
|
configRoot,
|
|
514
628
|
projectRoot: () => modState.root,
|
|
515
629
|
freeFolders: () => [modState.root, runtime.dataFolder],
|
|
630
|
+
freeAddresses: () => programAddresses(mod),
|
|
516
631
|
}
|
|
517
632
|
const checked = checkingGrants(claude, grants)
|
|
633
|
+
const places: Places = { home, configRoot, projectRoot: () => modState.root }
|
|
518
634
|
const files = createModFiles({
|
|
519
635
|
name: definition.name,
|
|
520
636
|
claude,
|
|
521
|
-
places
|
|
637
|
+
places,
|
|
522
638
|
checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
|
|
639
|
+
report,
|
|
523
640
|
})
|
|
524
641
|
const pages = new Map<string, { readonly title: string; readonly handle: PaneHandle }>()
|
|
525
642
|
const settingsPages: Mod<State>['settings'] = {
|
|
526
643
|
page(page) {
|
|
527
644
|
if (pages.has(page.id)) throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`)
|
|
645
|
+
if (page.id === changelogPage) throw new Error(`${definition.name}: the settings page id "${changelogPage}" opens the mod's CHANGELOG.md in /mods. Give the page another id.`)
|
|
528
646
|
pages.set(page.id, { title: page.title, handle: area.ui.pane(page) })
|
|
529
647
|
},
|
|
530
648
|
async open(pageId) {
|
|
@@ -540,81 +658,157 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
540
658
|
let cwd = startCwd
|
|
541
659
|
let loadedCwd = startCwd
|
|
542
660
|
let staleCwd: string | undefined
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
661
|
+
let forkedFrom: string | undefined
|
|
662
|
+
let parentAsked: Promise<string | undefined> | undefined
|
|
663
|
+
let hasEarlierPrompts = true
|
|
664
|
+
let agentType: string | undefined
|
|
665
|
+
const area = createUi<State>({ name: definition.name, claude, router, toaster, report, announce, mod: () => mod })
|
|
666
|
+
const askParent = async (): Promise<string | undefined> => {
|
|
667
|
+
if (definition.name === cmodPluginName) return undefined
|
|
668
|
+
try {
|
|
669
|
+
const parent = await claude.cmod.call({ to: cmodPluginName, method: 'forkParent', input: null })
|
|
670
|
+
return typeof parent === 'string' ? parent : undefined
|
|
671
|
+
} catch (error) {
|
|
672
|
+
claude.ui.log(`${definition.name} starts without the session values of the conversation it was forked from: ${messageOf(error)}`, { to: 'debug' })
|
|
673
|
+
return undefined
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const forkParent = () =>
|
|
677
|
+
(parentAsked ??= askParent().then((parent) => {
|
|
678
|
+
forkedFrom = parent
|
|
679
|
+
return parent
|
|
680
|
+
}))
|
|
681
|
+
const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, report, changed: area.changed, forkParent })
|
|
682
|
+
const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, report, changed: modState.changed })
|
|
546
683
|
const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
dataFolder: runtime.dataFolder,
|
|
554
|
-
on(event, hook, options) {
|
|
555
|
-
const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude)
|
|
556
|
-
if (!hookEvents.includes(event)) hookEvents.push(event)
|
|
557
|
-
if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, bounded as ModHook<'PreToolUse'>, claude, agents, held))
|
|
558
|
-
else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>, bounded as ModHook<Exclude<ModEvent, 'PreToolUse'>>, claude, agents))
|
|
684
|
+
const expand = (path: string) => (homePrefix.test(path) ? resolvedPath(path, places) : path)
|
|
685
|
+
const settingsFile = (source: 'user' | 'project' | 'local'): SettingsFile => ({
|
|
686
|
+
read: () => checked.settings.read({ source }),
|
|
687
|
+
async update(values) {
|
|
688
|
+
await checkSettings(grants, source, Object.keys(values))
|
|
689
|
+
await claude.cmod.call({ to: cmodPluginName, method: 'updateSettings', input: { mod: definition.name, source, project: modState.root, values } })
|
|
559
690
|
},
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
691
|
+
})
|
|
692
|
+
const client = createSessionsClient({ name: definition.name, claude: checked, report: (id, message, error) => report({ id, message, error }), isGranted: (permission) => flag(permission) })
|
|
693
|
+
let followed: Parameters<typeof client.follow>[0] | undefined
|
|
694
|
+
const followFolders = (project: string, folder: string) => {
|
|
695
|
+
if (followed === undefined) return
|
|
696
|
+
followed = { ...followed, project, cwd: folder }
|
|
697
|
+
client.follow(followed)
|
|
698
|
+
}
|
|
699
|
+
const imports: Imports = {
|
|
700
|
+
sessions: client.sessions,
|
|
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'),
|
|
578
716
|
},
|
|
717
|
+
model: { complete: (completion, options) => checked.model.complete(completion, options) },
|
|
579
718
|
fs: {
|
|
580
|
-
read: (path) => checked.fs.read(path),
|
|
581
|
-
write: (path, text) => checked.fs.write(path, text),
|
|
582
|
-
|
|
583
|
-
exists: (path) => checked.fs.exists(path),
|
|
584
|
-
stat: (path, options) => checked.fs.stat(path, options),
|
|
719
|
+
read: (path) => checked.fs.read(expand(path)),
|
|
720
|
+
write: (path, text) => checked.fs.write(expand(path), text),
|
|
721
|
+
exists: (path) => checked.fs.exists(expand(path)),
|
|
585
722
|
find: (glob) => files.find(glob),
|
|
723
|
+
list: async (path) => (await checked.fs.list(path === undefined ? undefined : expand(path))).map(({ name, kind, size, mtimeMs, isLink }) => ({ name, isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs })),
|
|
724
|
+
async stat(path, options) {
|
|
725
|
+
const { kind, size, mtimeMs, isLink, realPath } = await checked.fs.stat(expand(path), options)
|
|
726
|
+
return { isFile: kind === 'file', isFolder: kind === 'dir', isLink, size, modifiedAt: mtimeMs, ...(realPath === undefined ? {} : { realPath }) }
|
|
727
|
+
},
|
|
586
728
|
},
|
|
587
729
|
metadata: {
|
|
588
730
|
read: (path) => files.read(path),
|
|
589
731
|
update: (path, change) => files.update(path, change),
|
|
590
732
|
},
|
|
733
|
+
process: {
|
|
734
|
+
run: (argv, init) => checked.process.run(argv, init),
|
|
735
|
+
spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
|
|
736
|
+
},
|
|
591
737
|
http: { fetch: (url, init) => checked.http.fetch(url, init) },
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
738
|
+
}
|
|
739
|
+
const allowed = (permission: string): Allowed => ({ allows: (value) => isCovered(granted, itemOf(permission, value), home) })
|
|
740
|
+
const flag = (permission: string) => isCovered(granted, permission, home)
|
|
741
|
+
const timer = (ms: number, subject: string, start: (ms: number, run: () => void) => { cancel(): void }, listener: () => unknown): Disposable => {
|
|
742
|
+
let isRunning = false
|
|
743
|
+
const started = start(checkedMs(definition.name, `mod.${subject}`, ms), () => {
|
|
744
|
+
if (isRunning) return
|
|
745
|
+
isRunning = true
|
|
746
|
+
void Promise.resolve()
|
|
747
|
+
.then(() => {
|
|
748
|
+
bind(imports)
|
|
749
|
+
return listener()
|
|
750
|
+
})
|
|
751
|
+
.catch((error: unknown) => report({ id: `${subject}:${ms}`, message: `The ${subject} ${ms} ms timer failed`, error }))
|
|
752
|
+
.finally(() => {
|
|
753
|
+
isRunning = false
|
|
754
|
+
})
|
|
755
|
+
})
|
|
756
|
+
return { dispose: () => started.cancel() }
|
|
757
|
+
}
|
|
758
|
+
const mod: Mod<State, Declared> = {
|
|
759
|
+
name: definition.name,
|
|
760
|
+
folder: claude.plugin.root,
|
|
761
|
+
dataFolder: runtime.dataFolder,
|
|
762
|
+
state: modState.state,
|
|
763
|
+
get options() {
|
|
764
|
+
return modOptions.values as OptionValues<Declared>
|
|
603
765
|
},
|
|
604
|
-
agent: { spawn: (args) => checked.agent.spawn(args) },
|
|
605
|
-
model: { complete: (completion, options) => checked.model.complete(completion, options) },
|
|
606
766
|
permissions: {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
767
|
+
get conversation() {
|
|
768
|
+
return flag('conversation')
|
|
769
|
+
},
|
|
770
|
+
get prompt() {
|
|
771
|
+
return flag('prompt')
|
|
772
|
+
},
|
|
773
|
+
get model() {
|
|
774
|
+
return flag('model')
|
|
775
|
+
},
|
|
776
|
+
get tasks() {
|
|
777
|
+
return flag('tasks')
|
|
778
|
+
},
|
|
779
|
+
get tools() {
|
|
780
|
+
return flag('tools')
|
|
781
|
+
},
|
|
782
|
+
get approve() {
|
|
783
|
+
return flag('approve')
|
|
784
|
+
},
|
|
785
|
+
network: allowed('network'),
|
|
786
|
+
run: allowed('run'),
|
|
787
|
+
files: allowed('files'),
|
|
788
|
+
config: allowed('config'),
|
|
612
789
|
},
|
|
613
|
-
|
|
614
|
-
|
|
790
|
+
on(hook: Hook, matcherOrListener: unknown, listenerOrOptions?: unknown, maybeOptions?: HookOptions): Disposable {
|
|
791
|
+
const hasMatcher = typeof listenerOrOptions === 'function'
|
|
792
|
+
const listener = (hasMatcher ? listenerOrOptions : matcherOrListener) as HookListener<Hook>
|
|
793
|
+
const matcher = hasMatcher ? matcherOrListener : undefined
|
|
794
|
+
const options = (hasMatcher ? maybeOptions : listenerOrOptions) as HookOptions | undefined
|
|
795
|
+
if (!hooks.includes(hook)) throw new Error(`${definition.name}: mod.on takes one of Claude Code's hooks, such as PreToolUse, not ${String(hook)}.`)
|
|
796
|
+
const matched: HookListener<Hook> = matcher === undefined ? listener : (input) => (matches(input, matcher) ? listener(input) : undefined)
|
|
797
|
+
const bounded = options?.timeoutMs === undefined ? matched : timedHook(hook, matched, checkedMs(definition.name, `the ${hook} hook's timeoutMs`, options.timeoutMs), claude, toaster.toast)
|
|
798
|
+
if (!hooksAdded.includes(hook)) hooksAdded.push(hook)
|
|
799
|
+
if (hook === PreToolUse) return router.add('tool.call', preToolUseHook(definition.name, bounded as HookListener<typeof PreToolUse>, claude, agents, held))
|
|
800
|
+
const classic: Exclude<Hook, typeof PreToolUse> = hook
|
|
801
|
+
return router.add(`classic.${classic}`, classicHook(definition.name, classic, bounded as HookListener<typeof classic>, claude, agents, report))
|
|
615
802
|
},
|
|
803
|
+
every: (ms, listener) => timer(ms, 'every', claude.clock.every, listener),
|
|
804
|
+
after: (ms, listener) => timer(ms, 'after', claude.clock.after, listener),
|
|
805
|
+
use: (job) => job({ mod, announce, reserveName }),
|
|
806
|
+
log: (text) => claude.ui.log(`${definition.name}: ${text}`, { to: 'debug' }),
|
|
807
|
+
ui: { ...area.ui, notice: (callId, text) => claude.ui.notice(callId, text) },
|
|
808
|
+
settings: settingsPages,
|
|
616
809
|
dependencies: dependencyCalls(claude, (call, task) => beforeDeadline(claude, { ms: dependencyCallMs }, call, task)),
|
|
617
810
|
}
|
|
811
|
+
bind(imports)
|
|
618
812
|
const followSession = async (isAfterCd = false) => {
|
|
619
813
|
try {
|
|
620
814
|
const [nextRoot, reportedCwd] = await Promise.all([claude.session.root(), claude.session.cwd()])
|
|
@@ -625,8 +819,10 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
625
819
|
const nextCwd = staleCwd === undefined ? reportedCwd : nextRoot
|
|
626
820
|
if (!hasMovedRoot && nextCwd === oldCwd) return
|
|
627
821
|
cwd = nextCwd
|
|
822
|
+
followFolders(nextRoot, nextCwd)
|
|
628
823
|
await modState.moveTo(nextRoot).catch((error: unknown) => {
|
|
629
824
|
if (cwd === nextCwd) cwd = loadedCwd
|
|
825
|
+
followFolders(modState.root, cwd)
|
|
630
826
|
throw error
|
|
631
827
|
})
|
|
632
828
|
if (hasMovedRoot) await modOptions.load(nextRoot)
|
|
@@ -636,18 +832,34 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
636
832
|
const moved: HookInput<'CwdChanged'> = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
|
|
637
833
|
await router.dispatch('classic.CwdChanged', moved as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
|
|
638
834
|
} catch (error) {
|
|
639
|
-
|
|
835
|
+
report({ id: 'cmod:follow', message: `Keeps the state of ${modState.root} until the next prompt or folder move`, error })
|
|
640
836
|
}
|
|
641
837
|
}
|
|
838
|
+
const followCurrent = async (title: string | undefined) => {
|
|
839
|
+
const [id, model, usage] = await Promise.all([claude.session.id(), claude.session.model(), claude.session.usage()])
|
|
840
|
+
followed = { id, title: title ?? '', project: modState.root, cwd, model, startedAt: new Date(usage.startedAt).toISOString(), ...(forkedFrom === undefined ? {} : { forkedFrom }), ...(agentType === undefined ? {} : { agentType }) }
|
|
841
|
+
client.follow(followed)
|
|
842
|
+
}
|
|
642
843
|
router.add('classic.SessionStart', async (e, next) => {
|
|
643
|
-
|
|
644
|
-
|
|
844
|
+
agentType = e.agent_id === undefined ? e.agent_type : agentType
|
|
845
|
+
const previous = followed?.id
|
|
846
|
+
if (e.source === 'fork' && previous !== undefined && previous !== e.session_id) forkedFrom = previous
|
|
847
|
+
else if (e.session_id !== previous) {
|
|
848
|
+
forkedFrom = undefined
|
|
849
|
+
parentAsked = undefined
|
|
850
|
+
}
|
|
851
|
+
hasEarlierPrompts = e.source !== 'startup' && e.source !== 'clear'
|
|
852
|
+
await modState.switchSession(e.session_id, e.source, e.transcript_path).catch((error: unknown) => {
|
|
853
|
+
report({ id: 'cmod:session', message: `Kept its session values from before the ${e.source}`, error })
|
|
645
854
|
})
|
|
855
|
+
await followCurrent(e.session_title).catch((error: unknown) => report({ id: 'cmod:current', message: 'Kept the details of the session from before', error }))
|
|
646
856
|
return next(e)
|
|
647
857
|
})
|
|
648
858
|
router.add('classic.UserPromptSubmit', async (e, next) => {
|
|
649
859
|
staleCwd = undefined
|
|
650
860
|
await followSession()
|
|
861
|
+
const isPerson = e.source === undefined || e.source === 'user' || e.source === 'sdk'
|
|
862
|
+
await followCurrent(e.session_title || (isPerson && !hasEarlierPrompts && client.sessions.current.title === '' ? firstLine(e.prompt) : undefined)).catch((error: unknown) => report({ id: 'cmod:current', message: 'Kept the details of the session from before', error }))
|
|
651
863
|
return next(e)
|
|
652
864
|
})
|
|
653
865
|
for (const event of ['classic.PostToolUse', 'classic.PostToolUseFailure'] as const) {
|
|
@@ -658,49 +870,68 @@ async function createMod<State extends object, Declared extends Options>(definit
|
|
|
658
870
|
}
|
|
659
871
|
router.add('command.run', async (e, next) => {
|
|
660
872
|
const answer = await next(e)
|
|
661
|
-
if (e.command === 'cd')
|
|
873
|
+
if (e.command === 'cd') {
|
|
874
|
+
await followSession(true)
|
|
875
|
+
conversations?.moved()
|
|
876
|
+
}
|
|
662
877
|
return answer
|
|
663
878
|
})
|
|
664
879
|
router.add('skill.prompt', userSkillHook(claude))
|
|
665
880
|
const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input: never) => run(input, mod)]))
|
|
881
|
+
const conversations = definition.name === cmodPluginName ? serveConversations({ claude, router, store: runtime.store, project: () => modState.root, report: (id, message, error) => report({ id, message, error }) }) : undefined
|
|
666
882
|
router.add('cmod.call', async (e, next) => {
|
|
667
883
|
if (e.to !== definition.name) return next(e)
|
|
884
|
+
const call = e as typeof e & { readonly caller?: string }
|
|
885
|
+
if (e.method === conversationMethods.changed && call.caller === cmodPluginName) {
|
|
886
|
+
if (!flag('conversation')) return { value: null }
|
|
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)
|
|
889
|
+
return { value: null }
|
|
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
|
+
}
|
|
668
895
|
if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
|
|
669
896
|
if (e.method === openPageMethod) {
|
|
670
897
|
await settingsPages.open(String(e.input))
|
|
671
898
|
return { value: null }
|
|
672
899
|
}
|
|
673
|
-
|
|
900
|
+
const served = await conversations?.answer(call)
|
|
901
|
+
return served ?? answerCall(definition.name, api, e)
|
|
674
902
|
})
|
|
675
903
|
const agents = toolCalls(claude, router)
|
|
904
|
+
keepEngine(mod, { claude: checked, router, toolCalls: agents, report })
|
|
676
905
|
const names = new Set<string>()
|
|
677
|
-
const reserveName = (
|
|
678
|
-
const key = `${
|
|
906
|
+
const reserveName = (type: string, name: string, taken: string): Disposable => {
|
|
907
|
+
const key = `${type}:${name}`
|
|
679
908
|
if (names.has(key)) throw new Error(taken)
|
|
680
909
|
names.add(key)
|
|
910
|
+
return { dispose: () => void names.delete(key) }
|
|
681
911
|
}
|
|
682
912
|
await failsAs('its state did not load', () => modState.load())
|
|
683
913
|
await failsAs('its options did not load', () => modOptions.load(root))
|
|
684
914
|
const missing = modOptions.missing
|
|
685
915
|
if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
|
|
916
|
+
await failsAs('the details of its session did not load', () => followCurrent(undefined))
|
|
686
917
|
await failsAs('its setup function threw', () => definition.setup(mod))
|
|
687
918
|
const permissionHooks = permissionEvents.filter((event) => router.has(event))
|
|
688
919
|
if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
|
|
689
920
|
throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
|
|
690
921
|
}
|
|
691
|
-
if (held !== undefined &&
|
|
922
|
+
if (held !== undefined && hooksAdded.includes(PreToolUse)) router.add('tool.check', heldDecisionHook(held))
|
|
692
923
|
await failsAs('its open panes did not load', () => area.restorePanes())
|
|
693
|
-
if (
|
|
694
|
-
return { mod, added }
|
|
924
|
+
if (hooksAdded.length > 0) added.push(`${hooksAdded.length === 1 ? 'a hook' : 'hooks'} on ${listed(hooksAdded)}`)
|
|
925
|
+
return { mod, added, imports }
|
|
695
926
|
}
|
|
696
927
|
|
|
697
|
-
function checkingKeys(claude: Claude,
|
|
928
|
+
function checkingKeys(claude: Claude, toast: Toast, keys: Readonly<Record<string, string>>): Claude {
|
|
698
929
|
const bound = Object.entries(keys)
|
|
699
930
|
if (bound.length === 0) return claude
|
|
700
931
|
const register: Claude['command']['register'] = (command) => {
|
|
701
932
|
for (const [key, commandName] of bound) {
|
|
702
933
|
if (commandName === command.name && command.immediate !== true) {
|
|
703
|
-
|
|
934
|
+
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 })
|
|
704
935
|
}
|
|
705
936
|
}
|
|
706
937
|
return claude.command.register(command)
|
|
@@ -715,11 +946,11 @@ function checkedMs(name: string, subject: string, ms: number): number {
|
|
|
715
946
|
return ms
|
|
716
947
|
}
|
|
717
948
|
|
|
718
|
-
function timedHook<
|
|
949
|
+
function timedHook<H extends Hook>(event: H, hook: HookListener<H>, ms: number, claude: Claude, toast: Toast): HookListener<H> {
|
|
719
950
|
return (input) =>
|
|
720
951
|
new Promise((resolve, reject) => {
|
|
721
952
|
const timer = claude.clock.after(ms, () => {
|
|
722
|
-
|
|
953
|
+
toast.warning(`The ${event} hook passed its ${ms / 1000} s timeout`, { id: `timeout:${event}` })
|
|
723
954
|
resolve(undefined)
|
|
724
955
|
})
|
|
725
956
|
Promise.resolve()
|
|
@@ -745,25 +976,6 @@ async function failsAs(subject: string, step: () => unknown): Promise<void> {
|
|
|
745
976
|
}
|
|
746
977
|
}
|
|
747
978
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
let lastError = ''
|
|
751
|
-
let piece = await stream.next()
|
|
752
|
-
while (piece.done !== true) {
|
|
753
|
-
if (piece.value.stream === 'stderr') {
|
|
754
|
-
lastError = lastLineOf(piece.value.text) ?? lastError
|
|
755
|
-
} else {
|
|
756
|
-
const lines = `${pending}${piece.value.text}`.split('\n')
|
|
757
|
-
pending = lines.pop() ?? ''
|
|
758
|
-
for (const text of lines) onLine(text)
|
|
759
|
-
}
|
|
760
|
-
piece = await stream.next()
|
|
761
|
-
}
|
|
762
|
-
if (pending !== '') onLine(pending)
|
|
763
|
-
return { code: piece.value.code, lastError }
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
function lastLineOf(text: string): string | undefined {
|
|
767
|
-
const trimmed = text.trim()
|
|
768
|
-
return trimmed === '' ? undefined : trimmed.slice(trimmed.lastIndexOf('\n') + 1).trim()
|
|
979
|
+
function progressOf({ done, total, label }: Extract<RunnerEvent, { type: 'progress' }>): RenderElement {
|
|
980
|
+
return Box({ gap: 2, children: [Progress({ value: total > 0 ? (done / total) * 100 : 0 }), Text({ dimColor: true, wrap: 'truncate-end', children: label })] })
|
|
769
981
|
}
|