Discover, install, inspect, toggle, update and debug Claude Code mods (function-hook plugins).

A mod manager for Claude Code. Mods are plugins that hook into a Claude Code session: they can run programs, read the conversation, change what the model sees. Type /mods to see the mods you have and what each one can do, find new ones across your marketplaces, and install, switch off, update or remove them without leaving the session, each change reviewed first and undoable.
--plugin-dir, CLAUDE_CODE_PLUGIN_DIRS, your skills folder, folder marketplaces): validate, test, reload, share./mods list, info, doctor, export and friends answer as text for scripts and -p runs.
claude CLI (claude plugin list, install, enable, disable, update, uninstall, marketplace add/update, validate, test) for the changes you confirm, and Claude Code's plugin reload. It never writes a settings file and never passes -y; a command a marketplace declares runs only after you confirm that exact command (its sha256).https://raw.githubusercontent.com/: a catalogue index (ayagmar/claude-modmgr, branch catalog-index, at most every 12 hours) and, for a catalogue entry, its hooks/hooks.json and .claude-plugin/plugin.json at the entry's pinned commit. These reads are off under CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC or with the detectRemote option off. Update checks (updateCheckHours, default 6, 0 turns them off) ask the CLI to refresh your marketplaces.--debug, its own debug log for failed hooks. It never reads your settings or the CLI's own files.The full threat model is in docs/SECURITY.md.
Needs Claude Code 2.1.292 or newer. MIT licensed. Source, issues and the landing page: github.com/ayagmar/claude-modmgr · ayagmar.github.io/claude-modmgr.
hooks/register.tsx 386 lines1// The only file that spells `$` (the validator refuses `$` passed across an
2// import). It builds ports from `$` in top-level builders, one per noun (a
3// `$`-taking builder inside `register()` is refused), registers each event
4// once (a module may hook an event only once without a matcher), and
5// delegates to services. `.catch` handlers never touch `$`: on re-entry their
6// `$` calls reject, so they pass through or answer plainly.
7
8import {
9 atom,
10 type EngineInterface,
11 type Register,
12 type RenderInput,
13 read,
14 update,
15} from 'claude-code'
16import { parseConfig } from './domain/config.ts'
17import { devOfKey } from './domain/dev.ts'
18import { foundOfKey } from './domain/discover.ts'
19import { healthOfKey } from './domain/health.ts'
20import { INITIAL, type ModmgrState, type StateKey } from './domain/state.ts'
21import { FILTER_KEY, rowOfKey } from './domain/view.ts'
22import type {
23 ClockPort,
24 CommandPort,
25 EnvPort,
26 FsPort,
27 HttpPort,
28 Ports,
29 ProcessPort,
30 SessionPort,
31 StatePort,
32 StorePort,
33 UiPort,
34} from './ports.ts'
35import { type Actions, createActions } from './services/actions.ts'
36import { modsCommand } from './services/commands.ts'
37import {
38 MODS_DESCRIPTION,
39 onNotice,
40 onSessionStart,
41 onTurnEnd,
42 onTurnStart,
43} from './services/lifecycle.ts'
44import { createRuntime, newOwnerId, type Runtime } from './services/runtime.ts'
45import { drawBand } from './ui/Band.tsx'
46import type { El, ViewPorts } from './ui/kit.tsx'
47import { drawPane } from './ui/Pane.tsx'
48
49// One atom per key, its plugin and key spelled as literals (the validator lists
50// them) and a shape tag that changes with the key's type (domain/state.ts).
51const MODS = atom({ plugin: 'modmgr', key: 'mods' } as const, INITIAL.mods, { shape: 'mods/2' })
52const DETAIL = atom({ plugin: 'modmgr', key: 'detail' } as const, INITIAL.detail, {
53 shape: 'detail/2',
54})
55const CATALOG_PAGE = atom({ plugin: 'modmgr', key: 'catalogPage' } as const, INITIAL.catalogPage, {
56 shape: 'catalogPage/4',
57})
58const DETECT = atom({ plugin: 'modmgr', key: 'detect' } as const, INITIAL.detect, {
59 shape: 'detect/2',
60})
61const QUEUE = atom({ plugin: 'modmgr', key: 'queue' } as const, INITIAL.queue, { shape: 'queue/1' })
62const SYNC = atom({ plugin: 'modmgr', key: 'sync' } as const, INITIAL.sync, { shape: 'sync/1' })
63const VIEW = atom({ plugin: 'modmgr', key: 'view' } as const, INITIAL.view, { shape: 'view/8' })
64const REVIEW = atom({ plugin: 'modmgr', key: 'review' } as const, INITIAL.review, {
65 shape: 'review/4',
66})
67const ATTENTION = atom({ plugin: 'modmgr', key: 'attention' } as const, INITIAL.attention, {
68 shape: 'attention/2',
69})
70const DEGRADED = atom({ plugin: 'modmgr', key: 'degraded' } as const, INITIAL.degraded, {
71 shape: 'degraded/1',
72})
73const DEV = atom({ plugin: 'modmgr', key: 'dev' } as const, INITIAL.dev, { shape: 'dev/1' })
74const HEALTH = atom({ plugin: 'modmgr', key: 'health' } as const, INITIAL.health, {
75 shape: 'health/1',
76})
77
78type Change<K extends StateKey> = (value: ModmgrState[K]) => ModmgrState[K]
79
80function statePorts($: EngineInterface): StatePort {
81 // `read`/`update` take an atom named directly (the validator refuses a computed one).
82 const readers: { [K in StateKey]: () => Promise<ModmgrState[K]> } = {
83 mods: () => read($, MODS),
84 detail: () => read($, DETAIL),
85 catalogPage: () => read($, CATALOG_PAGE),
86 detect: () => read($, DETECT),
87 queue: () => read($, QUEUE),
88 sync: () => read($, SYNC),
89 view: () => read($, VIEW),
90 review: () => read($, REVIEW),
91 attention: () => read($, ATTENTION),
92 degraded: () => read($, DEGRADED),
93 dev: () => read($, DEV),
94 health: () => read($, HEALTH),
95 }
96 const writers: { [K in StateKey]: (change: Change<K>) => Promise<ModmgrState[K]> } = {
97 mods: change => update($, MODS, change),
98 detail: change => update($, DETAIL, change),
99 catalogPage: change => update($, CATALOG_PAGE, change),
100 detect: change => update($, DETECT, change),
101 queue: change => update($, QUEUE, change),
102 sync: change => update($, SYNC, change),
103 view: change => update($, VIEW, change),
104 review: change => update($, REVIEW, change),
105 attention: change => update($, ATTENTION, change),
106 degraded: change => update($, DEGRADED, change),
107 dev: change => update($, DEV, change),
108 health: change => update($, HEALTH, change),
109 }
110 return {
111 read: key => readers[key](),
112 update: <K extends StateKey>(key: K, change: Change<K>) =>
113 (writers[key] as (change: Change<K>) => Promise<ModmgrState[K]>)(change),
114 }
115}
116
117function processPorts($: EngineInterface): ProcessPort {
118 return {
119 run: (argv, init) => $.process.run(argv, init),
120 spawn: (argv, init) => $.process.spawn({ argv, ...init }),
121 }
122}
123
124function storePorts($: EngineInterface): StorePort {
125 return {
126 get: key => $.store.get(key),
127 set: (key, value) => $.store.set(key, value),
128 delete: key => $.store.delete(key),
129 keys: () => $.store.keys(),
130 }
131}
132
133function clockPorts($: EngineInterface): ClockPort {
134 return {
135 now: () => $.clock.now(),
136 after: (ms, fn) => $.clock.after(ms, fn),
137 every: (ms, fn) => $.clock.every(ms, fn),
138 }
139}
140
141function envPorts($: EngineInterface): EnvPort {
142 return {
143 pluginDirs: () => $.env.get('CLAUDE_CODE_PLUGIN_DIRS'),
144 nonessentialTraffic: () => $.env.get('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'),
145 configDir: () => $.env.get('CLAUDE_CONFIG_DIR'),
146 home: () => $.env.get('HOME'),
147 indexUrl: () => $.env.get('MODMGR_INDEX_URL'),
148 }
149}
150
151function sessionPorts($: EngineInterface): SessionPort {
152 return {
153 root: () => $.session.root(),
154 cwd: () => $.session.cwd(),
155 id: () => $.session.id(),
156 repo: () => $.session.repo(),
157 surfaces: () => $.session.surfaces(),
158 version: () => $.session.version(),
159 }
160}
161
162function commandPorts($: EngineInterface): CommandPort {
163 return {
164 registerMods: async () => {
165 await $.command.register({
166 name: 'mods',
167 description: MODS_DESCRIPTION,
168 argumentHint:
169 '[list | info | install | remove | update | enable | disable | doctor | export | apply]',
170 })
171 },
172 reloadPlugins: async () => (await $.command.run({ command: 'reload-plugins' })).text,
173 list: () => $.command.list(),
174 }
175}
176
177function uiPorts($: EngineInterface): UiPort {
178 return {
179 debug: text => $.ui.log(text, { to: 'debug' }),
180 panes: () => $.ui.panes(),
181 open: args => $.ui.open(args),
182 close: id => $.ui.close({ id }),
183 focus: async (requestId, key) => {
184 const moved = await $.ui.focus({ requestId, key })
185 // The engine doesn't raise modmgr's own `ui.focus` hook for this move.
186 if (moved.deny === undefined) noteRing(key)
187 return moved
188 },
189 copy: (text, surface) => $.ui.copy(surface === undefined ? { text } : { text, surface }),
190 status: text => $.ui.status(text),
191 }
192}
193
194function httpPorts($: EngineInterface): HttpPort {
195 return {
196 get: async (url, maxBytes) => {
197 const response = await $.http.fetch(url, { headers: { Range: `bytes=0-${maxBytes}` } })
198 // The file's start, whole when it is short; an empty file can't satisfy a range.
199 if (response.status === 206) return { status: 200, text: response.text }
200 if (response.status === 416) return { status: 200, text: '' }
201 return { status: response.status, text: response.text }
202 },
203 }
204}
205
206function fsPorts($: EngineInterface): FsPort {
207 return {
208 read: path => $.fs.read(path),
209 list: async path =>
210 (await $.fs.list(path)).map(entry => ({ name: entry.name, kind: entry.kind })),
211 }
212}
213
214function portsOf($: EngineInterface): Ports {
215 return {
216 process: processPorts($),
217 state: statePorts($),
218 store: storePorts($),
219 clock: clockPorts($),
220 env: envPorts($),
221 session: sessionPorts($),
222 command: commandPorts($),
223 ui: uiPorts($),
224 http: httpPorts($),
225 fs: fsPorts($),
226 }
227}
228
229function actionsOf($: EngineInterface): Actions {
230 return createActions({ state: statePorts($), ui: uiPorts($) }, runtime)
231}
232
233/** What a view draws with: the surface's elements, state reads (which subscribe), the actions. */
234function viewPortsOf($: EngineInterface, e: RenderInput): ViewPorts {
235 const table = $.ui.resolve(e)
236 // Mobile draws no Input or Select (d.ts Elements), though a table may carry
237 // them (the test kit's does, drawing them as nothing): ask the surface first.
238 const el: El =
239 e.surface !== 'mobile' && 'Input' in table
240 ? {
241 Box: table.Box,
242 Text: table.Text,
243 Button: table.Button,
244 Input: table.Input,
245 Select: table.Select,
246 }
247 : { Box: table.Box, Text: table.Text, Button: table.Button }
248 return { el, surface: e.surface, read: statePorts($).read, act: actionsOf($) }
249}
250
251// This module instance's services, built at its first `session.start` and
252// gone with the module (a reload of modmgr builds the next one).
253let runtime: Runtime | undefined
254
255// Whether modmgr's pane held the terminal's keys when last drawn. Esc hands
256// the keys back to the prompt before it raises `ui.close`, so the close
257// hook can't ask `$.ui.panes()` alone; the draw just before it knows, and the
258// pane redraws when the keys leave it. Module memory: a reload of modmgr
259// starts it false, and the first Esc then closes.
260let paneHadKeys = false
261
262// Whether the ring last landed on one of modmgr's keys rather than a row of
263// the list or its field: Esc then brings it back to the list first. Module
264// memory, set by every move the ring makes; one Esc spends it.
265let ringAway = false
266
267function noteRing(element: string | undefined): void {
268 ringAway =
269 element !== undefined &&
270 element !== FILTER_KEY &&
271 [rowOfKey, foundOfKey, devOfKey, healthOfKey].every(keyOf => keyOf(element) === undefined)
272}
273
274export const register: Register = (on, options) => {
275 const config = parseConfig(options)
276
277 on('session.start', async ($, e, next) => {
278 const started = performance.now()
279 runtime ??= createRuntime(portsOf($), config, newOwnerId())
280 await onSessionStart(runtime)
281 runtime.timing('session.start blocking', started)
282 return next(e)
283 }).catch((_$, e, next) => next(e))
284
285 // Observed only: the detector probes while no turn runs.
286 on('turn.start', async (_$, e, next) => {
287 onTurnStart(runtime, e.turnId)
288 return next(e)
289 }).catch((_$, e, next) => next(e))
290
291 on('turn.complete', async (_$, e, next) => {
292 try {
293 return await next(e)
294 } finally {
295 onTurnEnd(runtime, e.turnId, e.agentId)
296 }
297 }).catch((_$, e, next) => next(e))
298
299 // Observed only: what the session says when a hot-reloaded plugin fails (Dev).
300 on('session.append', { door: 'notice' }, async (_$, e, next) => {
301 const stored = await next(e)
302 onNotice(runtime, e.message.content)
303 return stored
304 }).catch((_$, e, next) => next(e))
305
306 on('command.run', { command: 'mods' }, ($, e) =>
307 modsCommand(
308 {
309 state: statePorts($),
310 ui: uiPorts($),
311 fs: fsPorts($),
312 clock: clockPorts($),
313 process: processPorts($),
314 session: sessionPorts($),
315 },
316 runtime,
317 e.args,
318 ),
319 ).catch(() => ({
320 text: 'modmgr failed to answer; run with --debug for the reason.',
321 exitCode: 1,
322 }))
323
324 on('ui.render', { component: 'Pane', requestId: 'modmgr' }, async ($, e) => {
325 // Esc closes from the terminal's keys; another surface's draw says nothing about them.
326 if (e.surface === 'terminal') paneHadKeys = e.props.isFocused
327 const started = performance.now()
328 const tree = await drawPane(viewPortsOf($, e), {
329 bodyColumns: e.props.bodyColumns,
330 bodyRows: e.props.scroll.bodyRows,
331 isFocused: e.props.isFocused,
332 })
333 // The label is built only when timings are on.
334 if (config.debugTimings) {
335 runtime?.timing(`pane draw (${e.surface}, ${e.props.bodyColumns} columns)`, started)
336 }
337 return tree
338 }).catch((_$, e, next) => next(e))
339
340 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
341 const started = performance.now()
342 const band = await drawBand(viewPortsOf($, e), e.props)
343 runtime?.timing('band draw', started)
344 return band ?? next(e)
345 }).catch((_$, e, next) => next(e))
346
347 // Matchers spell the pane id (domain/view.ts PANE_ID) as a literal: the
348 // validator names it, and scripts/validate-plugin.ts allows these two gates
349 // only on modmgr's own pane.
350
351 // The ring landing on a row makes it the selection (the window and the split
352 // follow). Selected as the ring moves, not after it, so the row's mark and the
353 // detail are drawn with the ring instead of a frame behind it.
354 on('ui.focus', { component: 'Pane', requestId: 'modmgr' }, async ($, e, next) => {
355 const id = rowOfKey(e.element)
356 const found = foundOfKey(e.element)
357 const dev = devOfKey(e.element)
358 const item = healthOfKey(e.element)
359 const act = actionsOf($)
360 const selecting =
361 id !== undefined
362 ? act.focusRow(id)
363 : found !== undefined
364 ? act.focusFound(found)
365 : dev !== undefined
366 ? act.focusDev(dev)
367 : item !== undefined
368 ? act.focusHealth(item)
369 : Promise.resolve()
370 const [moved] = await Promise.all([next(e), selecting])
371 if (moved.deny === undefined) noteRing(e.element)
372 return moved
373 }).catch((_$, e, next) => next(e))
374
375 // Esc pops an overlay, then brings the ring back to the list, then clears the
376 // filter, then closes.
377 on('ui.close', { id: 'modmgr' }, async ($, e, next) => {
378 const away = ringAway
379 ringAway = false
380 return e.origin.kind !== 'unload' &&
381 (await actionsOf($).closing(e.origin.kind, paneHadKeys, away))
382 ? { value: undefined }
383 : next(e)
384 }).catch((_$, e, next) => next(e))
385}
386hooks/domain/config.ts 42 lines1// modmgr's `userConfig` (plugin.json) read from `register(on, options)`. The
2// engine validates the values against the manifest; this reads them again so a
3// missing or mistyped one falls back to the default instead of reaching a timer.
4
5export type Config = {
6 /** Hours between update checks; 0 turns them off. */
7 readonly updateCheckHours: number
8 readonly detectRemote: boolean
9 readonly debugTimings: boolean
10}
11
12export const DEFAULT_CONFIG: Config = {
13 updateCheckHours: 6,
14 detectRemote: true,
15 debugTimings: false,
16}
17
18const MAX_HOURS = 168
19
20export const parseConfig = (options: Readonly<Record<string, unknown>>): Config => {
21 const hours = options.updateCheckHours
22 const detect = options.detectRemote
23 const timings = options.debugTimings
24 return {
25 updateCheckHours:
26 typeof hours === 'number' && Number.isFinite(hours)
27 ? Math.min(MAX_HOURS, Math.max(0, hours))
28 : DEFAULT_CONFIG.updateCheckHours,
29 detectRemote: typeof detect === 'boolean' ? detect : DEFAULT_CONFIG.detectRemote,
30 debugTimings: typeof timings === 'boolean' ? timings : DEFAULT_CONFIG.debugTimings,
31 }
32}
33
34/**
35 * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is on when set to anything but
36 * empty, `0` or `false`: when unsure, modmgr stays off the network.
37 */
38export const trafficOff = (value: string | undefined): boolean => {
39 const text = value?.trim().toLowerCase() ?? ''
40 return text !== '' && text !== '0' && text !== 'false'
41}
42hooks/domain/dev.ts 433 lines1// Dev: which mods this session runs from a folder the
2// person works in, how each is loaded, what its last validate and test said,
3// what the session reported while hot-reloading it, and how to share it.
4// services/dev.ts gathers the inputs; every decision lives here.
5
6import type { DevFailures, DevHow, DevRow, Job, JobState, Origin } from '../../types/index.d.ts'
7import type { InstalledEntry } from './cli-results.ts'
8import { safeSegments } from './detector.ts'
9import { isPluginName, parseAbsolutePath, splitPluginId } from './ids.ts'
10import { isRecord, parseJson, str } from './json.ts'
11import { originOf, rootOf, runningVersion } from './mods.ts'
12import { sanitize } from './sanitize.ts'
13
14/** A Dev row's Button key: what `ui.focus` and `ui.press` name. */
15export const DEV_PREFIX = 'dev:'
16export const devKey = (key: string): string => `${DEV_PREFIX}${key}`
17export const devOfKey = (key: string | undefined): string | undefined =>
18 key?.startsWith(DEV_PREFIX) === true ? key.slice(DEV_PREFIX.length) : undefined
19
20/** The order Dev lists its sections in. */
21export const DEV_SECTIONS: readonly DevHow[] = [
22 'session-folder',
23 'plugin-dir',
24 'env-dir',
25 'skills-dir',
26 'folder-marketplace',
27]
28
29/** A row's few words for how it is loaded (from 60 columns). */
30export const HOW_SHORT: Readonly<Record<DevHow, string>> = {
31 'session-folder': 'session',
32 'plugin-dir': '--plugin-dir',
33 'env-dir': 'env dirs',
34 'skills-dir': 'skills',
35 'folder-marketplace': 'folder mkt',
36}
37
38/** How it is loaded, in the detail. */
39export const SECTION_LABEL: Readonly<Record<DevHow, string>> = {
40 'session-folder': "This session's mods folder",
41 'plugin-dir': 'Loaded with --plugin-dir',
42 'env-dir': 'From CLAUDE_CODE_PLUGIN_DIRS',
43 'skills-dir': 'Your skills folder',
44 'folder-marketplace': 'Folder marketplaces',
45}
46
47/** How an edit to its files reaches this session (every action says when it applies). */
48export const appliesOf = (how: DevHow): string =>
49 how === 'folder-marketplace'
50 ? 'Its folder is read again at the next plugin reload (l).'
51 : 'Saving a file there reloads it in this session.'
52
53/** How to stop loading it, where the CLI can't; undefined when Installed toggles it. */
54export const stopLoadingOf = (row: DevRow): string | undefined => {
55 switch (row.how) {
56 case 'session-folder':
57 return 'It is loaded for this session only.'
58 case 'plugin-dir':
59 return 'To stop loading it, leave --plugin-dir out of your launch command.'
60 case 'env-dir':
61 return 'To stop loading it, take its folder out of CLAUDE_CODE_PLUGIN_DIRS and restart.'
62 default:
63 return undefined
64 }
65}
66
67const DEV_ORIGINS: ReadonlyMap<Origin, DevHow> = new Map([
68 ['env-dir', 'env-dir'],
69 ['skills-dir', 'skills-dir'],
70 ['folder-marketplace', 'folder-marketplace'],
71])
72
73/** A plugin the CLI lists, and whether validate found a hooks module (undefined: it couldn't read it). */
74export type Listed = { readonly entry: InstalledEntry; readonly mod: boolean | undefined }
75
76/** A plugin folder modmgr found, with what its manifest says. */
77export type Found = { readonly name: string; readonly path: string; readonly version?: string }
78
79export type DevSources = {
80 readonly listed: readonly Listed[]
81 /** The plugins that registered a command (`$.command.list()`), by name or id. */
82 readonly commandPlugins: readonly string[]
83 /** This session's mods folder's plugins. */
84 readonly sessionFolder: readonly Found[]
85 /** The folders found for plugins the CLI doesn't list (`--plugin-dir`), by name. */
86 readonly located: ReadonlyMap<string, Found>
87}
88
89/** The name part of a plugin as a command names it (`modmgr`, `spawner@inline`). */
90const bareName = (plugin: string): string => {
91 const at = plugin.indexOf('@')
92 return at < 0 ? plugin : plugin.slice(0, at)
93}
94
95/**
96 * The plugins this session runs that the CLI doesn't list, whose folder to
97 * look for: what `--plugin-dir` loaded (the flag isn't inherited by a
98 * child). A plugin that registered a command, or one the session reported
99 * failing. Built-in plugins register commands too (`cc-plugin-diff`, found
100 * live), so a name becomes a row only once its folder is found.
101 */
102export const unlistedPlugins = (
103 commandPlugins: readonly string[],
104 failing: readonly string[],
105 known: ReadonlySet<string>,
106): string[] =>
107 [...new Set([...commandPlugins.map(bareName), ...failing])]
108 .filter(name => isPluginName(name) && !known.has(name))
109 .sort()
110
111const byPlace = (a: DevRow, b: DevRow): number =>
112 DEV_SECTIONS.indexOf(a.how) - DEV_SECTIONS.indexOf(b.how) ||
113 a.name.localeCompare(b.name) ||
114 a.key.localeCompare(b.key)
115
116/**
117 * Dev's rows, by section then name: listed plugins loaded from a folder the
118 * person edits (not a plain plugin; one validate couldn't read stays, since a
119 * broken mod is what Dev is for), this session's mods folder, and the
120 * `--plugin-dir` plugins whose folder was found (`located`).
121 */
122export const devRowsOf = (sources: DevSources): DevRow[] => {
123 const rows: DevRow[] = []
124 const listedNames = new Set(sources.listed.map(({ entry }) => splitPluginId(entry.id).name))
125 for (const { entry, mod } of sources.listed) {
126 const how = DEV_ORIGINS.get(originOf(entry))
127 const path = rootOf(entry)
128 if (how === undefined || mod === false || path === undefined) continue
129 const version = runningVersion(entry)
130 rows.push({
131 key: path,
132 name: splitPluginId(entry.id).name,
133 how,
134 id: entry.id,
135 path,
136 enabled: entry.enabled,
137 ...(version === undefined ? {} : { version }),
138 })
139 }
140 // A plugin that registered a command is loaded (a session folder's may not be yet).
141 const loaded = new Set(sources.commandPlugins.map(bareName))
142 const sessionNames = new Set(sources.sessionFolder.map(found => found.name))
143 for (const found of sources.sessionFolder) {
144 // A folder the CLI lists under another heading is said once, there.
145 if (rows.some(row => row.path === found.path)) continue
146 rows.push({
147 key: found.path,
148 ...rowOfFound(found),
149 how: 'session-folder',
150 ...(loaded.has(found.name) ? { enabled: true } : {}),
151 })
152 }
153 for (const [name, found] of sources.located) {
154 if (listedNames.has(name) || sessionNames.has(name) || found.name !== name) continue
155 if (rows.some(row => row.path === found.path)) continue
156 rows.push({
157 key: found.path,
158 ...rowOfFound(found),
159 how: 'plugin-dir',
160 ...(loaded.has(name) ? { enabled: true } : {}),
161 })
162 }
163 return rows.sort(byPlace)
164}
165
166const rowOfFound = (found: Found): Pick<DevRow, 'name' | 'path' | 'version'> => ({
167 name: found.name,
168 path: found.path,
169 ...(found.version === undefined ? {} : { version: found.version }),
170})
171
172/** A `plugin.json`'s name and version, when it names a plugin. */
173export const manifestOf = (text: string): { name: string; version?: string } | undefined => {
174 const value = parseJson(text)
175 if (!isRecord(value)) return undefined
176 const name = str(value, 'name')
177 if (name === undefined || !isPluginName(name)) return undefined
178 const version = str(value, 'version')
179 return version === undefined ? { name } : { name, version: version.slice(0, 64) }
180}
181
182/**
183 * The folder a `marketplace.json` gives plugin `name`, relative to the
184 * marketplace's root (`''` for the root itself); undefined when it doesn't
185 * list it, or lists it at a path that is absolute or climbs.
186 */
187export const marketplaceFolderOf = (text: string, name: string): string | undefined => {
188 const value = parseJson(text)
189 if (!isRecord(value) || !Array.isArray(value.plugins)) return undefined
190 const entry = value.plugins.find(plugin => isRecord(plugin) && plugin.name === name)
191 if (!isRecord(entry) || typeof entry.source !== 'string') return undefined
192 const segments = safeSegments(entry.source)
193 return segments?.map(decodeURIComponent).join('/')
194}
195
196/** `a/b` joined under `root`, or `root` itself for `''`. */
197export const joinPath = (root: string, relative: string): string => {
198 const head = root.replace(/\/+$/, '')
199 return relative === '' ? head : `${head}/${relative}`
200}
201
202const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
203
204/** A session id that is one plain path segment. */
205export const isSessionId = (value: string): boolean => SESSION_ID.test(value)
206
207/**
208 * This session's mods folder: `<config dir>/dev-mods/<session id>`, the config
209 * dir being `CLAUDE_CONFIG_DIR` or `~/.claude`. Undefined when neither is an
210 * absolute path or the session id isn't one plain segment.
211 */
212export const sessionFolderOf = (how: {
213 readonly configDir: string | undefined
214 readonly home: string | undefined
215 readonly sessionId: string
216}): string | undefined => {
217 if (!SESSION_ID.test(how.sessionId)) return undefined
218 const config = configDirOf(how.configDir, how.home)
219 return config === undefined ? undefined : joinPath(config, `dev-mods/${how.sessionId}`)
220}
221
222/** Claude Code's config dir: `CLAUDE_CONFIG_DIR`, else `~/.claude`; undefined unless absolute. */
223export const configDirOf = (
224 configDir: string | undefined,
225 home: string | undefined,
226): string | undefined => {
227 const config =
228 configDir !== undefined && configDir !== ''
229 ? configDir
230 : home === undefined
231 ? undefined
232 : joinPath(home, '.claude')
233 return config?.startsWith('/') === true ? config : undefined
234}
235
236/** The row Dev shows as selected and acts on: the selection when listed, else the first. */
237export const devRowOf = (
238 selected: string | undefined,
239 rows: readonly DevRow[],
240): DevRow | undefined => rows.find(row => row.key === selected) ?? rows[0]
241
242/**
243 * The selection once rows change: the row shown selected before, while it is
244 * still listed, so a row that joins at the top (a failing folder) never takes
245 * the selection from under the focus ring (found live).
246 */
247export const keptSelection = (
248 selected: string | undefined,
249 before: readonly DevRow[],
250 after: readonly DevRow[],
251): string | undefined => {
252 const shown = devRowOf(selected, before)?.key
253 return after.some(row => row.key === shown) ? shown : after[0]?.key
254}
255
256// ---- what ran ----------------------------------------------------------------
257
258export type DevRunKind = 'validate' | 'test'
259
260/** The newest validate or test of `path` in the queue. */
261export const lastRun = (jobs: readonly Job[], kind: DevRunKind, path: string): Job | undefined =>
262 jobs.findLast(job => job.kind === kind && job.args?.path === path)
263
264export type RunMark = { readonly text: string; readonly tone: 'busy' | 'ok' | 'bad' | 'muted' }
265
266const BUSY: ReadonlySet<JobState> = new Set(['queued', 'running'])
267
268/** A row's short mark for its last validate: `✓`, `✓ 2 warnings`, `✗ 3`, `…`, or nothing yet. */
269export const validateMark = (job: Job | undefined): RunMark | undefined => {
270 if (job === undefined) return undefined
271 if (BUSY.has(job.state)) return { text: 'validating…', tone: 'busy' }
272 const report = job.report
273 if (job.state === 'ok') {
274 const warnings = report?.warnings ?? 0
275 return warnings === 0
276 ? { text: '✓ valid', tone: 'ok' }
277 : { text: `✓ ${warnings} ${warnings === 1 ? 'warning' : 'warnings'}`, tone: 'ok' }
278 }
279 if (job.state === 'failed' && report !== undefined) {
280 return { text: `✗ ${report.errors} ${report.errors === 1 ? 'error' : 'errors'}`, tone: 'bad' }
281 }
282 return job.state === 'failed'
283 ? { text: '✗ validate failed', tone: 'bad' }
284 : { text: `validate ${job.state}`, tone: 'muted' }
285}
286
287/** A row's short mark for its last test run. */
288export const testMark = (job: Job | undefined): RunMark | undefined => {
289 if (job === undefined) return undefined
290 if (BUSY.has(job.state)) return { text: 'testing…', tone: 'busy' }
291 if (job.state === 'ok') return { text: '✓ tests', tone: 'ok' }
292 if (job.state === 'failed') return { text: '✗ tests', tone: 'bad' }
293 return { text: `tests ${job.state}`, tone: 'muted' }
294}
295
296// ---- what the session reported --------------------------------------------------
297
298const NOTICE = /^([a-z0-9][a-z0-9._-]{0,63})(?:@[a-z0-9][a-z0-9._-]{0,63})?: ([\s\S]+)$/
299/** The words the engine's notices use for a hook or module that didn't work. */
300const FAILURE = /\b(?:fail(?:s|ed)?|refused|skipped|threw|not loaded|did not load|errors?)\b/i
301
302/**
303 * The plugin folder a hooks file's path names (`: <folder>/hooks/<file>`), the
304 * folder allowed to hold spaces.
305 */
306const HOOKS_FILE = /:\s(\/.+?)\/hooks\/[^\s/:`'"]+/
307/**
308 * The notices that name a module's file (`broken: reload failed, the previous
309 * version stays loaded: <folder>/hooks/register.ts, …`): only these say where a plugin is.
310 */
311const NAMES_FOLDER = /\b(?:reload failed|did not load)\b/
312
313export type Failure = { readonly name: string; readonly reason: string; readonly folder?: string }
314
315/**
316 * A session notice that says a plugin's hook or module failed:
317 * `<plugin>: <what happened>` (the engine's reference, §Drawing), with the plugin's
318 * folder when the notice names a file of its hooks (a module that didn't
319 * load or reload does). Undefined for any other notice (a reload's line, a
320 * command's output).
321 */
322export const failureOf = (text: string): Failure | undefined => {
323 const match = NOTICE.exec(text.trim())
324 const name = match?.[1]
325 const reason = match?.[2]
326 if (name === undefined || reason === undefined || !FAILURE.test(reason)) return undefined
327 const folder = NAMES_FOLDER.test(reason) ? HOOKS_FILE.exec(reason)?.[1] : undefined
328 const checked = folder === undefined ? undefined : parseAbsolutePath(folder)
329 return {
330 name,
331 reason: sanitize(reason, { max: 300 }),
332 ...(checked?.ok === true ? { folder: checked.value } : {}),
333 }
334}
335
336/** Counts one more failure of `name`. */
337export const recordFailure = (
338 failures: Readonly<Record<string, DevFailures>>,
339 failure: Failure,
340 at: number,
341): Record<string, DevFailures> => {
342 const folder = failure.folder ?? failures[failure.name]?.folder
343 return {
344 ...failures,
345 [failure.name]: {
346 count: (failures[failure.name]?.count ?? 0) + 1,
347 lastReason: failure.reason,
348 lastAt: at,
349 ...(folder === undefined ? {} : { folder }),
350 },
351 }
352}
353
354// ---- sharing (the engine's reference, §Sharing a mod) --------------------------
355
356/** `owner/repo` of a GitHub remote (https, ssh or scp form), or undefined. */
357export const githubRepoOf = (remote: string | null | undefined): string | undefined => {
358 if (remote === null || remote === undefined) return undefined
359 const match =
360 /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/.exec(
361 remote.trim(),
362 )
363 const owner = match?.[1]
364 const repo = match?.[2]
365 return owner === undefined || repo === undefined ? undefined : `${owner}/${repo}`
366}
367
368/** Whether `path` is `root` or inside it. */
369export const isInside = (path: string, root: string): boolean => {
370 const head = root.replace(/\/+$/, '')
371 return path === head || path.startsWith(`${head}/`)
372}
373
374export type Share = {
375 /** What another person types: `/plugin install <mod> --marketplace <owner>/<repo>`. */
376 readonly line: string
377 /** Whether `line` names a real repository (else `<owner>/<repo>` stands in it). */
378 readonly complete: boolean
379 /** The marketplace file to write beside `plugin.json`, when none lists the mod. */
380 readonly snippet?: string
381 readonly notes: readonly string[]
382}
383
384/**
385 * How to share a dev mod: the one install line, and what is missing for it to
386 * work (a marketplace file listing the mod, a GitHub repository, a folder kept
387 * outside this session's mods folder).
388 */
389export const shareOf = (
390 row: Pick<DevRow, 'name' | 'how'>,
391 how: {
392 readonly repo: string | undefined
393 readonly listed: boolean
394 /** The mod's folder relative to the repository's root (`./`, `./plugin`). */
395 readonly source: string
396 },
397): Share => {
398 const repo = how.repo ?? '<owner>/<repo>'
399 const notes: string[] = []
400 if (row.how === 'session-folder') {
401 notes.push(
402 "Copy it out of this session's mods folder to a folder you keep, and make that the repository.",
403 )
404 }
405 if (how.repo === undefined) {
406 notes.push('Push its folder to a GitHub repository; <owner>/<repo> is that repository.')
407 }
408 const snippet = how.listed
409 ? undefined
410 : JSON.stringify(
411 {
412 name: row.name,
413 owner: { name: '<your name>' },
414 plugins: [{ name: row.name, source: how.source }],
415 },
416 null,
417 2,
418 )
419 if (snippet !== undefined) {
420 notes.push(
421 how.source === './'
422 ? 'Write this as .claude-plugin/marketplace.json beside its plugin.json:'
423 : "Write this as .claude-plugin/marketplace.json at the repository's root:",
424 )
425 }
426 return {
427 line: `/plugin install ${row.name} --marketplace ${repo}`,
428 complete: how.repo !== undefined,
429 ...(snippet === undefined ? {} : { snippet }),
430 notes,
431 }
432}
433hooks/domain/discover.ts 229 lines1// Discover's logic: what a catalogue row says, the install
2// review, the review that accepts a marketplace-declared command, and
3// adding a marketplace. The catalogue itself lives in services/catalog.ts.
4
5import type {
6 CatalogPage,
7 CatalogRow,
8 CommunityFacts,
9 Job,
10 ReviewRequest,
11 Scope,
12 View,
13} from '../../types/index.d.ts'
14import { capabilitiesOf, notableText } from './capabilities.ts'
15import { installIdOf, SORTS } from './catalog.ts'
16import type { CommunityMod } from './community.ts'
17import { parseMarketplaceSource } from './ids.ts'
18import { sanitize } from './sanitize.ts'
19
20export const INSTALL_SCOPES = ['user', 'project', 'local'] as const
21export type InstallScope = (typeof INSTALL_SCOPES)[number]
22
23export const isInstallScope = (value: unknown): value is InstallScope =>
24 typeof value === 'string' && (INSTALL_SCOPES as readonly string[]).includes(value)
25
26/** What a scope means, for the review's Select. */
27export const SCOPE_LABEL: Readonly<Record<InstallScope, string>> = {
28 user: 'user: every project',
29 project: 'project: this repository, shared',
30 local: 'local: this repository, only you',
31}
32
33/** What modmgr could read about an entry before installing it (a local source's `validate`). */
34export type Inspection = {
35 readonly notable: readonly string[]
36 readonly hasModule: boolean
37}
38
39/** A local entry modmgr tried to read and couldn't, and why. */
40export type Unread = { readonly failed: string }
41
42export const isUnread = (value: Inspection | Unread | undefined): value is Unread =>
43 value !== undefined && 'failed' in value
44
45/**
46 * The review of installing `entry` (`i`): the scope (user by default), what it
47 * can do when modmgr could read it, else that it couldn't (a remote source is
48 * read only once installed).
49 */
50export const installReview = (
51 entry: { readonly id: string; readonly name: string; readonly version?: string | undefined },
52 scope: InstallScope,
53 read: Inspection | Unread | undefined,
54): ReviewRequest => {
55 const inspection = isUnread(read) ? undefined : read
56 const review: ReviewRequest = {
57 action: 'install',
58 targets: [
59 {
60 id: entry.id,
61 op: 'install',
62 scope,
63 ...(entry.version === undefined ? {} : { version: entry.version }),
64 },
65 ],
66 notable: (inspection?.notable ?? []).map(id => `${entry.name}: ${notableText(id)}`),
67 changesRepoFile: scope !== 'user',
68 }
69 if (inspection !== undefined) return review
70 return isUnread(read)
71 ? { ...review, uninspected: true, unreadable: read.failed }
72 : { ...review, uninspected: true }
73}
74
75/**
76 * The review of installing a community mod (`i`): from the marketplace at its
77 * repository's root, which the install adds first. What it can do is known
78 * already, from the community index; modmgr reads it again once installed.
79 * Undefined for a mod no marketplace lists (it can't be installed by id).
80 */
81export const communityInstallReview = (
82 mod: CommunityMod,
83 scope: InstallScope,
84): ReviewRequest | undefined => {
85 const id = installIdOf(mod)
86 if (id === undefined) return undefined
87 const name = sanitize(mod.name, { max: 64 })
88 return {
89 action: 'install',
90 targets: [{ id, op: 'install', scope }],
91 notable: capabilitiesOf(mod).notable.map(notable => `${name}: ${notableText(notable)}`),
92 changesRepoFile: scope !== 'user',
93 source: mod.repo,
94 indexedAt: mod.commit,
95 }
96}
97
98/** A community mod's page on GitHub (its folder at the commit the index read). */
99export const communityLink = (facts: Pick<CommunityFacts, 'repo' | 'path' | 'commit'>): string =>
100 facts.path === ''
101 ? `https://github.com/${facts.repo}`
102 : `https://github.com/${facts.repo}/tree/${facts.commit}/${facts.path}`
103
104/** The same review at another scope (the review's Select). */
105export const withScope = (review: ReviewRequest, scope: Scope): ReviewRequest =>
106 review.action !== 'install' || !isInstallScope(scope)
107 ? review
108 : {
109 ...review,
110 targets: review.targets.map(target => ({ ...target, scope })),
111 changesRepoFile: scope !== 'user',
112 }
113
114/**
115 * The review that accepts what a failed install or update was stopped by:
116 * the command a marketplace declares (or the headers helper that fetches its
117 * archive), verbatim, with its sha256. Confirming passes the sha with
118 * `--accept-command`; the CLI runs it only if the command it would run still
119 * has that sha, and otherwise shows the new one, which this review
120 * shows again. Undefined for a job that wasn't stopped that way.
121 */
122export const acceptReview = (job: Job): ReviewRequest | undefined => {
123 const { shown, target } = job
124 if (shown === undefined || target === undefined) return undefined
125 if (job.kind !== 'install' && job.kind !== 'update') return undefined
126 const scope = job.args?.scope
127 const declared = {
128 text: shown.command,
129 sha256: shown.sha256,
130 ...(shown.truncated === true ? { truncated: true } : {}),
131 }
132 const source = job.args?.source
133 return {
134 action: job.kind,
135 targets: [{ id: target, op: job.kind, ...(scope === undefined ? {} : { scope }) }],
136 notable: [],
137 changesRepoFile: scope === 'project' || scope === 'local',
138 ...(source === undefined ? {} : { source }),
139 ...(shown.kind === 'entry_helper'
140 ? { headersHelper: declared }
141 : { declaredCommand: declared }),
142 }
143}
144
145/**
146 * The newest failed job that waits for its declared command to be reviewed:
147 * none once a later install or update of the same mod was queued or ran.
148 */
149export const awaitingAcceptance = (jobs: readonly Job[]): Job | undefined => {
150 const at = jobs.findLastIndex(job => job.state === 'failed' && job.shown !== undefined)
151 const stopped = jobs[at]
152 if (stopped === undefined) return undefined
153 const settled = jobs
154 .slice(at + 1)
155 .some(job => job.target === stopped.target && (job.kind === 'install' || job.kind === 'update'))
156 return settled ? undefined : stopped
157}
158
159/**
160 * The review of adding a marketplace (`m`), or why the source can't be one.
161 * Adding fetches the catalogue (a clone for a repository) and writes your
162 * settings; it runs no plugin code.
163 */
164export const marketplaceReview = (
165 text: string,
166): { readonly review: ReviewRequest } | { readonly error: string } => {
167 const source = parseMarketplaceSource(text.trim())
168 if (!source.ok) return { error: source.error.message }
169 return {
170 review: {
171 action: 'marketplace',
172 targets: [],
173 notable: [],
174 changesRepoFile: false,
175 source: source.value,
176 },
177 }
178}
179
180/** The notable lines of an inspection, for the detail. */
181export const inspectionLines = (inspection: Inspection): string[] =>
182 inspection.notable.map(notableText)
183
184/** The marketplace field's key. */
185export const MARKETPLACE_KEY = 'marketplace-source'
186
187/** A Discover row's Button key: what `ui.focus` and `ui.press` name. */
188export const FOUND_PREFIX = 'found:'
189export const foundKey = (id: string): string => `${FOUND_PREFIX}${id}`
190export const foundOfKey = (key: string | undefined): string | undefined =>
191 key?.startsWith(FOUND_PREFIX) === true ? key.slice(FOUND_PREFIX.length) : undefined
192
193/** The catalogue row Discover shows as selected and acts on: the selection when drawn, else the first. */
194export const foundRow = (view: View, page: CatalogPage): CatalogRow | undefined =>
195 page.rows.find(row => row.id === view.found) ?? page.rows[0]
196
197/** What the sort key says: installs, then stars for community mods. */
198export const SORT_LABEL: Readonly<Record<View['sort'], string>> = {
199 installs: 'popularity',
200 stars: 'stars',
201 name: 'name',
202 marketplace: 'source',
203}
204
205/** The next sort (`o`): popularity → stars → name → source. */
206export const nextSort = (sort: View['sort']): View['sort'] =>
207 SORTS[(SORTS.indexOf(sort) + 1) % SORTS.length] ?? 'installs'
208
209/**
210 * `74 mods`, `74 mods among 1,804 of 3,544 checked`, or while it runs
211 * `12 mods so far · checking 1,804 of 3,544`; undefined before it ran.
212 */
213export const detectLine = (detect: {
214 readonly checked: number
215 readonly total: number
216 readonly found: number
217 readonly running: boolean
218}): string | undefined => {
219 if (detect.total === 0) return undefined
220 const n = (value: number) => value.toLocaleString('en-US')
221 const mods = `${n(detect.found)} ${detect.found === 1 ? 'mod' : 'mods'}`
222 if (detect.running)
223 return detect.checked === 0
224 ? `checking ${n(detect.total)} entries…`
225 : `${mods} so far · checking ${n(detect.checked)} of ${n(detect.total)}`
226 if (detect.checked >= detect.total) return mods
227 return `${mods} among ${n(detect.checked)} of ${n(detect.total)} checked`
228}
229hooks/domain/health.ts 369 lines1// Health: what needs the person's attention, grouped by mod,
2// each with a one-press fix where one exists, then modmgr's own state (the
3// installed list's refresh, a reload owed, the detector, the cache, the update
4// checks). Built from `$.state` alone, so it is drawn, never fetched.
5
6import type {
7 Attention,
8 Degraded,
9 DetectProgress,
10 DevState,
11 HealthFacts,
12 JobQueue,
13 ModRow,
14 Sync,
15} from '../../types/index.d.ts'
16import { notableVerb } from './capabilities.ts'
17import { isActive } from './jobs.ts'
18import { sanitize } from './sanitize.ts'
19import { bytesLabel, type Window, whyLocked, whyNoUpdate, windowAround } from './view.ts'
20
21/** What pressing an item does. */
22export type HealthFix =
23 | { readonly kind: 'update' | 'open'; readonly id: string }
24 | { readonly kind: 'validate'; readonly key: string }
25 | { readonly kind: 'copy'; readonly text: string }
26 | { readonly kind: 'reload' | 'refresh' | 'clear-cache' | 'check-updates' }
27
28export type HealthTone = 'bad' | 'warn' | 'info'
29
30export type HealthItem = {
31 /** Stable: the Button's key. */
32 readonly key: string
33 /** The mod it concerns, or modmgr's own heading. */
34 readonly group: string
35 readonly tone: HealthTone
36 readonly text: string
37 readonly fix?: HealthFix
38 /** What the fix does, in a word or two. */
39 readonly fixLabel?: string
40}
41
42/** modmgr's own heading (apart from an installed mod named modmgr), and the hook-order notes'. */
43export const OWN_GROUP = 'modmgr itself'
44export const ORDER_GROUP = 'Hook order'
45
46/** A Health item's Button key: what `ui.focus` and `ui.press` name. */
47export const HEALTH_PREFIX = 'health:'
48export const healthKey = (key: string): string => `${HEALTH_PREFIX}${key}`
49export const healthOfKey = (key: string | undefined): string | undefined =>
50 key?.startsWith(HEALTH_PREFIX) === true ? key.slice(HEALTH_PREFIX.length) : undefined
51
52const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
53
54/** `just now`, `5 min ago`, `3 h ago`, `2 days ago`. */
55export const agoLabel = (ms: number): string => {
56 const minutes = Math.floor(Math.max(0, ms) / 60_000)
57 if (minutes < 1) return 'just now'
58 if (minutes < 60) return `${minutes} min ago`
59 const hours = Math.floor(minutes / 60)
60 if (hours < 48) return `${hours} h ago`
61 return `${Math.floor(hours / 24)} days ago`
62}
63
64export type HealthInput = {
65 readonly mods: readonly ModRow[]
66 readonly dev: DevState
67 readonly attention: Attention
68 readonly degraded: Degraded
69 readonly sync: Sync
70 readonly detect: DetectProgress
71 readonly queue: JobQueue
72 readonly facts: HealthFacts
73}
74
75const TONE_ORDER: Readonly<Record<HealthTone, number>> = { bad: 0, warn: 1, info: 2 }
76
77/** Each mod's items: what is wrong first. */
78const modItems = (input: HealthInput): HealthItem[] => {
79 const items: HealthItem[] = []
80 const devKeyOf = new Map(input.dev.rows.map(row => [row.name, row.key]))
81 for (const row of input.mods) {
82 const group = sanitize(row.name, { max: 40 })
83 if (row.problems > 0) {
84 items.push({
85 key: `${row.id}:validate`,
86 group,
87 tone: 'bad',
88 text: `validate finds ${plural(row.problems, 'error', 'errors')} in it`,
89 fix: { kind: 'open', id: row.id },
90 fixLabel: 'see it',
91 })
92 }
93 if (row.capsNew !== undefined) {
94 const added = row.capsNew.added.map(notableVerb).join(', ')
95 items.push({
96 key: `${row.id}:caps`,
97 group,
98 tone: 'warn',
99 text: `since ${sanitize(row.capsNew.since, { max: 20 })} it can ${added}`,
100 fix: { kind: 'open', id: row.id },
101 fixLabel: 'review it',
102 })
103 }
104 if (row.updateTo !== undefined && whyNoUpdate(row) === undefined) {
105 items.push({
106 key: `${row.id}:update`,
107 group,
108 tone: 'info',
109 text: `${sanitize(row.updateTo, { max: 20 })} is available`,
110 fix: { kind: 'update', id: row.id },
111 fixLabel: 'update',
112 })
113 }
114 }
115 // Failures the session reported while hot-reloading (Dev's), and the debug log's.
116 const names = new Set([...Object.keys(input.dev.failures), ...Object.keys(input.facts.logged)])
117 for (const name of [...names].sort()) {
118 const group = sanitize(name, { max: 40 })
119 const failed = input.dev.failures[name]
120 const devKey = devKeyOf.get(name)
121 if (failed !== undefined) {
122 items.push({
123 key: `${name}:failures`,
124 group,
125 tone: 'bad',
126 text: `${plural(failed.count, 'failure', 'failures')} while it reloaded; last: ${failed.lastReason}`,
127 ...(devKey === undefined
128 ? {}
129 : { fix: { kind: 'validate' as const, key: devKey }, fixLabel: 'validate' }),
130 })
131 }
132 const logged = input.facts.logged[name]
133 if (logged !== undefined) {
134 items.push({
135 key: `${name}:logged`,
136 group,
137 tone: 'bad',
138 text: `a hook failed: ${sanitize(logged, { max: 160 })} (debug log)`,
139 })
140 }
141 }
142 // By mod, worst first; the mods in Installed's order, then the rest by name.
143 const order = [...input.mods.map(row => sanitize(row.name, { max: 40 }))]
144 const rank = (group: string) => {
145 const at = order.indexOf(group)
146 return at < 0 ? order.length : at
147 }
148 const worst = new Map<string, number>()
149 for (const item of items) {
150 worst.set(item.group, Math.min(worst.get(item.group) ?? 9, TONE_ORDER[item.tone]))
151 }
152 return items.sort(
153 (a, b) =>
154 (worst.get(a.group) ?? 9) - (worst.get(b.group) ?? 9) ||
155 rank(a.group) - rank(b.group) ||
156 a.group.localeCompare(b.group) ||
157 TONE_ORDER[a.tone] - TONE_ORDER[b.tone],
158 )
159}
160
161/** modmgr's own state, and the load states in one line. */
162const ownItems = (input: HealthInput): HealthItem[] => {
163 const { attention, degraded, sync, detect, facts, queue } = input
164 const items: HealthItem[] = []
165 const own = (item: Omit<HealthItem, 'group'>) => items.push({ ...item, group: OWN_GROUP })
166 if (degraded.process) {
167 own({ key: 'own:process', tone: 'bad', text: sanitize(degraded.reason ?? '', { max: 300 }) })
168 }
169 if (sync.error !== undefined) {
170 own({
171 key: 'own:sync',
172 tone: 'bad',
173 text: `couldn't read the installed list: ${sanitize(sync.error.message, { max: 160 })}`,
174 fix: { kind: 'refresh' },
175 fixLabel: 'try again',
176 })
177 }
178 const reloadQueued = queue.jobs.some(job => job.kind === 'reload' && isActive(job))
179 if (attention.reloadPending && !reloadQueued) {
180 own({
181 key: 'own:reload',
182 tone: 'warn',
183 text: 'changes wait for a plugin reload',
184 fix: { kind: 'reload' },
185 fixLabel: 'reload',
186 })
187 }
188 if (facts.cache.full) {
189 own({
190 key: 'own:cache',
191 tone: 'bad',
192 text: "modmgr's cache is full; what it knows is not saved",
193 fix: { kind: 'clear-cache' },
194 fixLabel: 'clear cache',
195 })
196 }
197 if (degraded.acceptCommand) {
198 own({
199 key: 'own:accept',
200 tone: 'info',
201 text: 'Claude Code refuses declared-command acceptances from this session; accept them in a terminal',
202 })
203 }
204 const counts = loadStates(input.mods)
205 if (counts !== undefined) own({ key: 'own:load', tone: 'info', text: counts })
206 const updates = facts.updates
207 // Said once the facts are in (the first frame has none, and would say "every 0 hours").
208 if (facts.at !== undefined)
209 own({
210 key: 'own:updates',
211 tone: 'info',
212 text:
213 updates.off !== undefined
214 ? `update checks are off: ${updates.off}`
215 : `updates checked ${updates.at === undefined || facts.at === undefined ? 'never' : agoLabel(facts.at - updates.at)}, every ${plural(updates.every, 'hour', 'hours')}`,
216 ...(updates.off === undefined
217 ? { fix: { kind: 'check-updates' as const }, fixLabel: 'check now' }
218 : {}),
219 })
220 const left = Math.max(0, facts.detector.budget - facts.detector.spent)
221 // Where Discover's kinds came from: the catalogue index (and how old it is), then checks here.
222 const index =
223 detect.indexAt === undefined || facts.at === undefined
224 ? ''
225 : `, from the catalogue index built ${agoLabel(facts.at - detect.indexAt)}`
226 own({
227 key: 'own:detector',
228 tone: 'info',
229 text:
230 detect.total === 0
231 ? 'detector: not run yet; it starts when Discover opens'
232 : facts.detector.remote
233 ? `detector: ${detect.found} mods found${index}; ${detect.checked.toLocaleString('en-US')} of ${detect.total.toLocaleString('en-US')} checked, ${left} requests left this session`
234 : `detector: local catalogues only (${facts.detector.why ?? 'remote checks are off'}); ${detect.found} mods found`,
235 })
236 if (!facts.cache.full) {
237 own({
238 key: 'own:cache',
239 tone: 'info',
240 text: `cache: ${bytesLabel(facts.cache.bytes)}`,
241 fix: { kind: 'clear-cache' },
242 fixLabel: 'clear',
243 })
244 }
245 const log = facts.debugLog
246 if (log.state === 'too-big' && log.path !== undefined) {
247 own({
248 key: 'own:debug',
249 tone: 'warn',
250 text: "this session's debug log is too large to read here (over 4 MiB)",
251 fix: { kind: 'copy', text: `grep 'hook failed closed' ${log.path}` },
252 fixLabel: 'copy a search',
253 })
254 }
255 if (log.state === 'none') {
256 own({
257 key: 'own:debug',
258 tone: 'info',
259 text: 'A hook that fails is logged only in a session started with --debug',
260 fix: { kind: 'copy', text: 'claude --debug' },
261 fixLabel: 'copy command',
262 })
263 }
264 return items
265}
266
267/** `4 enabled · 1 disabled · 1 managed · 2 from the launch command`, or nothing with no mods. */
268export const loadStates = (mods: readonly ModRow[]): string | undefined => {
269 if (mods.length === 0) return undefined
270 const managed = mods.filter(row => row.scope === 'managed').length
271 const launched = mods.filter(
272 row => row.origin === 'env-dir' || row.origin === 'plugin-dir',
273 ).length
274 const other = mods.filter(row => whyLocked(row) === undefined)
275 const enabled = other.filter(row => row.enabled).length
276 const disabled = other.length - enabled
277 return [
278 `${enabled} enabled`,
279 disabled === 0 ? '' : `${disabled} disabled`,
280 managed === 0 ? '' : `${managed} managed`,
281 launched === 0 ? '' : `${launched} from the launch command`,
282 ]
283 .filter(part => part !== '')
284 .join(' · ')
285}
286
287/** Every item Health draws: the mods', the hook-order notes, then modmgr's own. */
288export const healthItemsOf = (input: HealthInput): HealthItem[] => [
289 ...modItems(input),
290 ...input.facts.chain.map(
291 (note): HealthItem => ({
292 key: `order:${note.event}`,
293 group: ORDER_GROUP,
294 tone: 'info',
295 text: sanitize(note.text, { max: 300 }),
296 }),
297 ),
298 ...ownItems(input),
299]
300
301/** How many items say something is wrong (the tab's `▲n`). */
302export const problemCount = (items: readonly HealthItem[]): number =>
303 items.filter(item => item.tone === 'bad').length
304
305/**
306 * The last `hook failed closed: <plugin>: errorKind=… (<event>; …)` line per
307 * plugin in a debug log (the engine logs the error's length, not its text).
308 */
309export const loggedFailures = (log: string): Record<string, string> => {
310 const found: Record<string, string> = {}
311 const line =
312 /hook failed closed: ([a-z0-9][a-z0-9._-]{0,63})(?:@[a-z0-9._-]+)?: errorKind=(\w+)[^(]*\(([\w.]+)/g
313 for (const match of log.matchAll(line)) {
314 const [, name, kind, event] = match
315 if (name !== undefined && kind !== undefined && event !== undefined) {
316 found[name] = `${event} (${kind})`
317 }
318 }
319 return found
320}
321
322/** A row of Health's list: a group's name, or one of its items. */
323export type HealthLine =
324 | { readonly kind: 'group'; readonly group: string }
325 | { readonly kind: 'item'; readonly item: HealthItem }
326
327/**
328 * The rows of Health's list that fit in `rows` around `items[at]`: each
329 * group's name on a row of its own above its items, again at the top when the
330 * window starts inside a group. `items` is the window over the items, for
331 * the pager.
332 */
333export const healthLines = (
334 items: readonly HealthItem[],
335 at: number,
336 rows: number,
337): { readonly lines: HealthLine[]; readonly items: Window } => {
338 const all: HealthLine[] = []
339 items.forEach((item, index) => {
340 if (index === 0 || items[index - 1]?.group !== item.group)
341 all.push({ kind: 'group', group: item.group })
342 all.push({ kind: 'item', item })
343 })
344 const size = Math.max(2, rows)
345 const selected = items[at]
346 const focus = all.findIndex(line => line.kind === 'item' && line.item === selected)
347 const window = windowAround(all.length, focus, size)
348 let lines = all.slice(window.start, window.end)
349 const first = lines[0]
350 if (first?.kind === 'item') {
351 lines = [{ kind: 'group', group: first.item.group }, ...lines]
352 // Over by the name: cut the end, or the top item when that would take the
353 // selection or the row under it (the arrows' way down).
354 const near = lines.slice(-2).some(line => line.kind === 'item' && line.item === selected)
355 if (near && lines[1] !== undefined && !(lines[1].kind === 'item' && lines[1].item === selected))
356 lines.splice(1, 1)
357 else lines.pop()
358 // A top item cut away leaves the next group's name right under this one.
359 if (lines[1]?.kind === 'group') lines.shift()
360 }
361 // A group's name with none of its items under it.
362 if (lines.at(-1)?.kind === 'group') lines.pop()
363 const shown = lines.flatMap(line => (line.kind === 'item' ? [items.indexOf(line.item)] : []))
364 return {
365 lines,
366 items: { start: shown[0] ?? 0, end: (shown.at(-1) ?? -1) + 1 },
367 }
368}
369hooks/domain/state.ts 83 lines1// What each `$.state` key reads before anything is written, and the shape tag
2// its atom is kept under. Bump a key's tag whenever its type in
3// types/index.d.ts changes: the new module then reads the old value as absent.
4
5import type {
6 Attention,
7 CatalogPage,
8 Degraded,
9 DetectProgress,
10 DevState,
11 HealthFacts,
12 JobQueue,
13 ModDetail,
14 ModRow,
15 ReviewRequest,
16 Sync,
17 View,
18} from '../../types/index.d.ts'
19
20/** `PluginState['modmgr']` with each `Shaped<T>` read as its `T`. */
21export type ModmgrState = {
22 mods: ModRow[]
23 detail: ModDetail | null
24 catalogPage: CatalogPage
25 detect: DetectProgress
26 queue: JobQueue
27 sync: Sync
28 view: View
29 review: ReviewRequest | null
30 attention: Attention
31 degraded: Degraded
32 dev: DevState
33 health: HealthFacts
34}
35
36export type StateKey = keyof ModmgrState
37
38export const SHAPES: Readonly<Record<StateKey, string>> = {
39 mods: 'mods/2',
40 detail: 'detail/2',
41 catalogPage: 'catalogPage/4',
42 detect: 'detect/2',
43 queue: 'queue/1',
44 sync: 'sync/1',
45 view: 'view/8',
46 review: 'review/4',
47 attention: 'attention/2',
48 degraded: 'degraded/1',
49 dev: 'dev/1',
50 health: 'health/1',
51}
52
53export const INITIAL_VIEW: View = {
54 tab: 'installed',
55 stack: [],
56 query: '',
57 search: '',
58 sort: 'installs',
59 staged: {},
60}
61
62export const INITIAL: Readonly<ModmgrState> = {
63 mods: [],
64 detail: null,
65 catalogPage: { rows: [], total: 0, community: 0, matched: 0, offset: 0, loading: false },
66 detect: { checked: 0, total: 0, found: 0, running: false },
67 queue: { owner: '', jobs: [] },
68 sync: { refreshing: false, skipped: 0 },
69 view: INITIAL_VIEW,
70 review: null,
71 attention: { updates: 0, problems: 0, reloadPending: false, capsChanged: 0 },
72 degraded: { process: false, network: false, acceptCommand: false },
73 dev: { rows: [], failures: {}, loading: false },
74 health: {
75 chain: [],
76 logged: {},
77 debugLog: { state: 'none' },
78 detector: { spent: 0, budget: 0, remote: false },
79 cache: { bytes: 0, full: false },
80 updates: { every: 0 },
81 },
82}
83hooks/domain/view.ts 789 lines1// The pane's logic as pure functions: which rows show, the staged
2// toggles and the review they become, the Esc cascade, the band's line and
3// the status line. services/actions.ts applies them to `$.state`; ui/ draws
4// what they return.
5
6import type {
7 Attention,
8 Job,
9 JobQueue,
10 ModRow,
11 Overlay,
12 ReviewOp,
13 ReviewRequest,
14 ReviewTarget,
15 Scope,
16 View,
17} from '../../types/index.d.ts'
18import { argvOf, commandOfJob } from './argv.ts'
19import { notableText } from './capabilities.ts'
20import { capsLine } from './caps-history.ts'
21import { isActive, type JobSpec, NEEDS_RELOAD, type UndoStep } from './jobs.ts'
22import { sanitize } from './sanitize.ts'
23
24/** The name part of a plugin id (`turn-band` of `turn-band@fixtures`). */
25export const nameOf = (id: string): string => {
26 const at = id.indexOf('@')
27 return at < 0 ? id : id.slice(0, at)
28}
29
30/** The one pane modmgr opens. */
31export const PANE_ID = 'modmgr'
32export const PANE_TITLE = 'mods'
33
34/** From this many body columns the list and the detail sit side by side. */
35export const SPLIT_MIN_COLUMNS = 80
36
37export type Layout = 'stacked' | 'split'
38
39/** The filter field's key. */
40export const FILTER_KEY = 'filter'
41
42/** A row's Button key: what `ui.focus` and `ui.press` name. */
43export const ROW_PREFIX = 'row:'
44export const rowKey = (id: string): string => `${ROW_PREFIX}${id}`
45/** The mod id a focused element names, when it is a row. */
46export const rowOfKey = (key: string | undefined): string | undefined =>
47 key?.startsWith(ROW_PREFIX) === true ? key.slice(ROW_PREFIX.length) : undefined
48
49export const layoutFor = (bodyColumns: number): Layout =>
50 bodyColumns >= SPLIT_MIN_COLUMNS ? 'split' : 'stacked'
51
52/** The list's columns beside the detail: about two fifths, never cramped or sprawling. */
53export const listColumnsFor = (bodyColumns: number): number =>
54 Math.max(28, Math.min(46, Math.floor(bodyColumns * 0.4)))
55
56/** How a dialog open asks: `holdToasts` only while nothing streams. */
57export type PaneOpen = {
58 readonly id: string
59 readonly title: string
60 readonly closeOnEscape: true
61 readonly focus?: true
62 readonly holdToasts?: true
63 readonly rows?: number
64 readonly columns?: number
65}
66
67/**
68 * Rows a dialog asks for inline: the list plus its chrome, at least 14 (a
69 * detail or a review is taller than a short list), never more than 24.
70 */
71export const rowsFor = (mods: number): number => Math.min(24, Math.max(14, mods + 6))
72
73/** Columns a docked dialog asks for: room for the list and the detail side by side. */
74export const DOCK_COLUMNS = 96
75
76export const paneOpen = (how: {
77 readonly focus: boolean
78 readonly hold: boolean
79 readonly mods: number
80 readonly title?: string
81}): PaneOpen => ({
82 id: PANE_ID,
83 title: how.title ?? PANE_TITLE,
84 closeOnEscape: true,
85 rows: rowsFor(how.mods),
86 columns: DOCK_COLUMNS,
87 ...(how.focus ? { focus: true as const } : {}),
88 ...(how.hold ? { holdToasts: true as const } : {}),
89})
90
91/**
92 * Whether an open of the dialog holds other plugins' toasts: only while
93 * nothing runs or waits and the job log isn't on top, since a pane that stays
94 * open must not silence them for long.
95 */
96export const holdsToasts = (queue: JobQueue, view: View): boolean =>
97 !queue.jobs.some(isActive) && view.stack.at(-1) !== 'jobs'
98
99// ---- rows -------------------------------------------------------------------
100
101/** Installed rows matching the filter: by name or id, case-insensitive. */
102export const filterRows = (rows: readonly ModRow[], query: string): ModRow[] => {
103 const needle = query.trim().toLowerCase()
104 if (needle === '') return [...rows]
105 return rows.filter(
106 row => row.name.toLowerCase().includes(needle) || row.id.toLowerCase().includes(needle),
107 )
108}
109
110/** The rows `[start, end)` a window of `size` shows. */
111export type Window = { readonly start: number; readonly end: number }
112
113/**
114 * A window of `size` rows over `count`, centred on `index` where the list
115 * allows, so the rows on either side of the focused one are always drawn and
116 * the arrows walk onto them (the ring moves only between drawn elements).
117 */
118export const windowAround = (count: number, index: number, size: number): Window => {
119 const rows = Math.max(1, Math.floor(size))
120 if (count <= rows) return { start: 0, end: Math.max(0, count) }
121 const at = Math.min(Math.max(0, index), count - 1)
122 const start = Math.min(Math.max(0, at - Math.floor((rows - 1) / 2)), count - rows)
123 return { start, end: start + rows }
124}
125
126/**
127 * A window of `size` rows over `count` that stays where it was (`previous`, its
128 * first row) while `index` moves inside it, and shifts only as far as needed
129 * when `index` reaches its edge, keeping one row beyond the selection drawn. So
130 * the highlight moves down a still list, as in any list, instead of the list
131 * moving under it at every key. With no previous window it centres on `index`.
132 */
133export const windowFollowing = (
134 previous: number | undefined,
135 count: number,
136 index: number,
137 size: number,
138): Window => {
139 const rows = Math.max(1, Math.floor(size))
140 if (count <= rows) return { start: 0, end: Math.max(0, count) }
141 if (previous === undefined) return windowAround(count, index, rows)
142 const at = Math.min(Math.max(0, index), count - 1)
143 const margin = rows >= 4 ? 1 : 0
144 let start = Math.min(Math.max(0, previous), count - rows)
145 if (at < start + margin) start = at - margin
146 else if (at > start + rows - 1 - margin) start = at - (rows - 1 - margin)
147 start = Math.min(Math.max(0, start), count - rows)
148 return { start, end: start + rows }
149}
150
151/** `6–15 of 40`, or undefined when every row shows. */
152export const pagerLabel = (window: Window, count: number): string | undefined =>
153 window.start === 0 && window.end >= count
154 ? undefined
155 : `${(window.start + 1).toLocaleString('en-US')}–${window.end.toLocaleString('en-US')} of ${count.toLocaleString('en-US')}`
156
157/** The row the selection moves to when `id` goes: the next one shown, else the one before. */
158export const neighbourOf = (
159 view: View,
160 mods: readonly ModRow[],
161 id: string,
162): ModRow | undefined => {
163 const rows = filterRows(mods, view.query)
164 const at = rows.findIndex(row => row.id === id)
165 if (at < 0) return undefined
166 return rows[at + 1] ?? rows[at - 1]
167}
168
169/** The selected row's index among `rows`, 0 when it isn't there. */
170export const selectedIndex = (rows: readonly ModRow[], selected: string | undefined): number => {
171 const index = rows.findIndex(row => row.id === selected)
172 return index < 0 ? 0 : index
173}
174
175/**
176 * The row the pane shows as selected and every action acts on: the selection
177 * when the filter shows it, else the first row shown.
178 * One resolver, so the drawing and a press never disagree.
179 */
180export const selectedRow = (view: View, mods: readonly ModRow[]): ModRow | undefined => {
181 const rows = filterRows(mods, view.query)
182 return rows.find(row => row.id === view.selected) ?? rows[0]
183}
184
185// ---- staging ----------------------------------------------------------------
186
187/**
188 * Stages a toggle of `row`: flips what it will be after apply. Staging it
189 * back to its current state un-stages it. A row the CLI can't toggle is
190 * left alone.
191 */
192export const stageToggle = (view: View, row: ModRow): View => {
193 if (!row.toggleable) return view
194 const { [row.id]: current, ...rest } = view.staged
195 const target = !(current ?? row.enabled)
196 return { ...view, staged: target === row.enabled ? rest : { ...rest, [row.id]: target } }
197}
198
199export type Change = { readonly row: ModRow; readonly enable: boolean }
200
201/** The staged toggles that still change something, in row order. */
202export const stagedChanges = (view: View, rows: readonly ModRow[]): Change[] =>
203 rows.flatMap(row => {
204 const enable = view.staged[row.id]
205 return enable === undefined || enable === row.enabled || !row.toggleable
206 ? []
207 : [{ row, enable }]
208 })
209
210/** The ids whose staged entry still changes something: what rows and the detail mark as staged. */
211export const stagedIds = (view: View, rows: readonly ModRow[]): ReadonlySet<string> =>
212 new Set(stagedChanges(view, rows).map(change => change.row.id))
213
214/** Drops staged entries that no longer change anything (a refresh moved under them). */
215export const pruneStaged = (view: View, rows: readonly ModRow[]): View => {
216 const kept = Object.fromEntries(stagedChanges(view, rows).map(c => [c.row.id, c.enable]))
217 return Object.keys(kept).length === Object.keys(view.staged).length
218 ? view
219 : { ...view, staged: kept }
220}
221
222/** Why a row can't be toggled from here, in the person's terms; undefined when it can. */
223export const whyLocked = (row: ModRow): string | undefined => {
224 if (row.toggleable) return undefined
225 if (row.scope === 'managed') return "managed by your organisation; it can't be turned off here"
226 if (row.origin === 'env-dir')
227 return 'loaded from CLAUDE_CODE_PLUGIN_DIRS; take it out of that variable and restart to stop loading it'
228 if (row.origin === 'plugin-dir')
229 return 'loaded with --plugin-dir; leave the flag out of your launch command to stop loading it'
230 return "this session loaded it from its launch command; it can't be toggled here"
231}
232
233const cliScope = (scope: Scope | undefined): 'user' | 'project' | 'local' | undefined =>
234 scope === 'user' || scope === 'project' || scope === 'local' ? scope : undefined
235
236/** Why the CLI can't update a row, in the person's terms; undefined when it can. */
237export const whyNoUpdate = (row: ModRow): string | undefined => {
238 if (row.scope === 'managed') return 'managed by your organisation; it updates with their settings'
239 if (row.origin === 'folder-marketplace') return 'runs from its marketplace folder: no updates'
240 if (row.origin === 'skills-dir')
241 return 'lives in your skills folder; it changes when its files do'
242 return whyLocked(row)
243}
244
245/** Why the CLI can't remove a row; undefined when it can. */
246export const whyNoRemove = (row: ModRow): string | undefined => {
247 if (row.scope === 'managed') return "managed by your organisation; it can't be removed here"
248 if (row.origin === 'skills-dir') return 'lives in your skills folder; delete its folder there'
249 return whyLocked(row)
250}
251
252/** What a review needs to know about one mod beyond its row. */
253export type ReviewFacts = {
254 readonly notable: readonly string[]
255 readonly parts?: { readonly skills: number; readonly agents: number; readonly mcp: number }
256 /** Its data folder's size, when it has one. */
257 readonly dataBytes?: number
258}
259
260type Parts = { skills: number; agents: number; mcp: number }
261
262const addParts = (into: Parts, parts: ReviewFacts['parts']): void => {
263 if (parts === undefined) return
264 into.skills += parts.skills
265 into.agents += parts.agents
266 into.mcp += parts.mcp
267}
268
269const withParts = (review: ReviewRequest, parts: Parts): ReviewRequest =>
270 parts.skills + parts.agents + parts.mcp > 0 ? { ...review, parts } : review
271
272const targetOf = (row: ModRow, op: ReviewOp): ReviewTarget => {
273 const scope = cliScope(row.scope)
274 return {
275 id: row.id,
276 op,
277 ...(scope === undefined ? {} : { scope }),
278 ...(row.version === undefined ? {} : { version: row.version }),
279 }
280}
281
282const touchesRepo = (scope: Scope | undefined): boolean => scope === 'project' || scope === 'local'
283
284/** The review a batch of toggles opens: one confirm for the whole batch. */
285export const toggleReview = (
286 changes: readonly Change[],
287 facts: (id: string) => ReviewFacts | undefined,
288): ReviewRequest => {
289 const notable: string[] = []
290 const parts = { skills: 0, agents: 0, mcp: 0 }
291 for (const { row, enable } of changes) {
292 const known = facts(row.id)
293 if (enable) {
294 for (const id of known?.notable ?? []) notable.push(`${row.name}: ${notableText(id)}`)
295 } else {
296 addParts(parts, known?.parts)
297 }
298 }
299 return withParts(
300 {
301 action: 'toggle',
302 targets: changes.map(({ row, enable }) => targetOf(row, enable ? 'enable' : 'disable')),
303 notable,
304 changesRepoFile: changes.some(({ row }) => touchesRepo(row.scope)),
305 },
306 parts,
307 )
308}
309
310/** The marketplace a plugin id names. */
311export const marketplaceOf = (id: string): string => id.slice(id.indexOf('@') + 1)
312
313/**
314 * The review of updating `rows` (`u` one, `a` all): each marketplace is
315 * refreshed first, then each mod updated, then one reload. It runs new code,
316 * so it is always reviewed; it can't be undone.
317 */
318export const updateReview = (rows: readonly ModRow[]): ReviewRequest => {
319 const marketplaces = [...new Set(rows.map(row => marketplaceOf(row.id)))].sort()
320 return {
321 action: 'update',
322 targets: rows.map(row => targetOf(row, 'update')),
323 notable: [],
324 changesRepoFile: false,
325 marketplaces,
326 }
327}
328
329/**
330 * The review of removing `row` (`x`): what goes, from which scope, its other
331 * parts and its data, which is kept unless the person asks otherwise.
332 */
333export const removeReview = (row: ModRow, facts: ReviewFacts | undefined): ReviewRequest => {
334 const parts = { skills: 0, agents: 0, mcp: 0 }
335 addParts(parts, facts?.parts)
336 const review: ReviewRequest = {
337 action: 'remove',
338 targets: [targetOf(row, 'remove')],
339 notable: [],
340 changesRepoFile: touchesRepo(row.scope),
341 keepData: true,
342 }
343 return withParts(
344 facts?.dataBytes === undefined ? review : { ...review, dataBytes: facts.dataBytes },
345 parts,
346 )
347}
348
349const UNDO_OP: Readonly<Partial<Record<JobSpec['kind'], ReviewOp>>> = {
350 enable: 'enable',
351 disable: 'disable',
352 install: 'install',
353 remove: 'remove',
354}
355
356/**
357 * The review of an undo that reinstalls (the inverse of a remove): what comes
358 * back, what it can do, and that a declared install command stops it.
359 */
360export const undoReview = (
361 plan: { readonly batch: string; readonly steps: readonly UndoStep[] },
362 rows: readonly ModRow[],
363 facts: (id: string) => ReviewFacts | undefined,
364): ReviewRequest => {
365 const { steps } = plan
366 const notable: string[] = []
367 const parts = { skills: 0, agents: 0, mcp: 0 }
368 const targets = steps.flatMap(({ spec, undoes }): ReviewTarget[] => {
369 const op = UNDO_OP[spec.kind]
370 if (op === undefined || spec.target === undefined) return []
371 // A queue job's target is checked only when it runs: drawn, it is sanitised.
372 const name = sanitize(rows.find(row => row.id === spec.target)?.name ?? nameOf(spec.target), {
373 max: 40,
374 })
375 const known = facts(spec.target)
376 if (op === 'install' || op === 'enable') {
377 for (const id of known?.notable ?? []) notable.push(`${name}: ${notableText(id)}`)
378 } else {
379 addParts(parts, known?.parts)
380 }
381 const scope = cliScope(spec.args?.scope)
382 return [
383 {
384 id: spec.target,
385 op,
386 ...(scope === undefined ? {} : { scope }),
387 // What the CLI said it did with the data, not what modmgr asked.
388 ...(op === 'install' && undoes.keptData !== undefined ? { keptData: undoes.keptData } : {}),
389 },
390 ]
391 })
392 return withParts(
393 {
394 action: 'undo',
395 targets,
396 notable,
397 changesRepoFile: targets.some(target => touchesRepo(target.scope)),
398 undoes: plan.batch,
399 },
400 parts,
401 )
402}
403
404/** The jobs a confirmed review queues (the batch's reload is added by `enqueue`). */
405export const specsOf = (review: ReviewRequest): JobSpec[] => {
406 if (review.action === 'marketplace') {
407 return review.source === undefined
408 ? []
409 : [{ kind: 'marketplace-add', args: { source: review.source } }]
410 }
411 const refresh: JobSpec[] = (review.marketplaces ?? []).map(name => ({
412 kind: 'marketplace-update',
413 target: name,
414 }))
415 // The sha of the command shown: the CLI runs it only while it still matches.
416 const accepted = (review.declaredCommand ?? review.headersHelper)?.sha256
417 const ops = review.targets.map((target): JobSpec => {
418 const scope = cliScope(target.scope)
419 const scoped = scope === undefined ? {} : { scope }
420 const kind = target.op
421 // An install from a marketplace not added yet names its source.
422 const from =
423 review.action === 'install' && kind === 'install' && review.source !== undefined
424 ? { source: review.source }
425 : {}
426 const args: Job['args'] =
427 kind === 'remove'
428 ? { ...scoped, keepData: review.keepData !== false }
429 : (kind === 'install' || kind === 'update') && accepted !== undefined
430 ? { ...scoped, ...from, acceptSha: accepted }
431 : { ...scoped, ...from }
432 return Object.keys(args).length === 0
433 ? { kind, target: target.id }
434 : { kind, target: target.id, args }
435 })
436 return [...refresh, ...ops]
437}
438
439/** The CLI line a job runs, for the review's "Runs" lines; undefined when it can't be built. */
440export const commandLine = (spec: JobSpec): string | undefined => {
441 const job: Job = { id: 'review', kind: spec.kind, state: 'queued', tail: [] }
442 const command = commandOfJob({
443 ...job,
444 ...(spec.target === undefined ? {} : { target: spec.target }),
445 ...(spec.args === undefined ? {} : { args: spec.args }),
446 })
447 return command.ok ? argvOf(command.value).join(' ') : undefined
448}
449
450/** `2 skills, 1 MCP server`; empty when there is nothing. */
451export const partsLabel = (parts: {
452 readonly skills: number
453 readonly agents: number
454 readonly mcp: number
455}): string => {
456 const say = (n: number, one: string, many: string) =>
457 n === 0 ? [] : [`${n} ${n === 1 ? one : many}`]
458 return [
459 ...say(parts.skills, 'skill', 'skills'),
460 ...say(parts.agents, 'agent', 'agents'),
461 ...say(parts.mcp, 'MCP server', 'MCP servers'),
462 ].join(', ')
463}
464
465/** `12 KB`: a data folder's size, rounded. */
466export const bytesLabel = (n: number): string =>
467 n < 1024
468 ? `${n} B`
469 : n < 1024 * 1024
470 ? `${Math.round(n / 1024)} KB`
471 : `${(n / 1048576).toFixed(1)} MB`
472
473/**
474 * Rows `texts` wrap to at `columns`, each at least one (a wrapped Text's
475 * height): words move whole to the next row, as the terminal wraps them, and
476 * a word longer than the row is cut across rows.
477 */
478export const wrappedRows = (texts: readonly string[], columns: number): number => {
479 const width = Math.max(1, columns)
480 const rowsOf = (text: string): number => {
481 let rows = 1
482 let used = 0
483 for (const word of text.split(' ')) {
484 const length = [...word].length
485 const need = used === 0 ? length : used + 1 + length
486 if (need <= width) {
487 used = need
488 continue
489 }
490 if (used > 0) rows += 1
491 rows += Math.max(0, Math.ceil(length / width) - 1)
492 used = length % width === 0 && length > 0 ? width : length % width
493 }
494 return rows
495 }
496 return texts.reduce((sum, text) => sum + rowsOf(text), 0)
497}
498
499/**
500 * Rows a wrapping row of items takes at `columns` (the footer's keys, laid
501 * out with `columnGap`): each line fills until the next item would overflow.
502 */
503export const footerRowsFor = (widths: readonly number[], columns: number, gap = 2): number => {
504 const line = Math.max(1, columns)
505 let rows = 1
506 let used = 0
507 for (const width of widths) {
508 const need = used === 0 ? width : used + gap + width
509 if (used > 0 && need > line) {
510 rows += 1
511 used = width
512 } else {
513 used = need
514 }
515 // An item wider than the line wraps inside itself; the next starts on a new line.
516 if (used > line) {
517 rows += Math.ceil(used / line) - 1
518 used = line
519 }
520 }
521 return rows
522}
523
524// ---- overlays and Esc -------------------------------------------------------
525
526export const topOverlay = (view: View): Overlay | undefined => view.stack.at(-1)
527
528/** Pushes an overlay; one already on the stack moves to the top instead of repeating. */
529export const pushOverlay = (view: View, overlay: Overlay): View => ({
530 ...view,
531 stack: [...view.stack.filter(item => item !== overlay), overlay],
532})
533
534export const popOverlay = (view: View): View =>
535 view.stack.length === 0 ? view : { ...view, stack: view.stack.slice(0, -1) }
536
537/** Opens an overlay, or closes it when it is already on top (the `h` and `j` keys). */
538export const toggleOverlay = (view: View, overlay: Overlay): View =>
539 topOverlay(view) === overlay ? popOverlay(view) : pushOverlay(view, overlay)
540
541export type Escape =
542 | { readonly kind: 'pop'; readonly view: View }
543 | { readonly kind: 'to-list' }
544 | { readonly kind: 'clear-query'; readonly view: View }
545 | { readonly kind: 'close' }
546
547/**
548 * What an Esc does: while the pane holds the keys it
549 * pops the top overlay, then brings the ring back to the list from a key
550 * (`ringAway`: beside the list, in the footer), then clears the filter, then
551 * closes. An Esc at the prompt (the pane not focused) always closes: the
552 * person can't see what a cascade would pop.
553 */
554export const escapeStep = (view: View, paneFocused: boolean, ringAway = false): Escape => {
555 if (!paneFocused) return { kind: 'close' }
556 if (view.stack.length > 0) return { kind: 'pop', view: popOverlay(view) }
557 if (ringAway) return { kind: 'to-list' }
558 // The field of the tab shown: Installed's filter, or Discover's search.
559 if (view.tab === 'discover' && view.search !== '')
560 return { kind: 'clear-query', view: { ...view, search: '' } }
561 if (view.tab === 'installed' && view.query !== '')
562 return { kind: 'clear-query', view: { ...view, query: '' } }
563 return { kind: 'close' }
564}
565
566/** The view a closed pane leaves: no overlays, no review half-done; staged toggles stay. */
567export const closedView = (view: View): View => {
568 const { notice: _notice, ...rest } = view
569 return { ...rest, stack: [] }
570}
571
572// ---- jobs: the batch line, the band, the status line and the title ------------
573
574const describeJob = (job: Job): string => {
575 const what =
576 job.kind === 'reload'
577 ? 'reload plugins'
578 : job.kind === 'marketplace-update'
579 ? 'refresh marketplace'
580 : job.kind
581 return job.target === undefined ? what : `${what} ${sanitize(job.target, { max: 60 })}`
582}
583
584/** The jobs of the newest batch, oldest first. */
585export const latestBatch = (jobs: readonly Job[]): Job[] => {
586 const batch = jobs.findLast(job => job.batch !== undefined)?.batch
587 return batch === undefined ? [] : jobs.filter(job => job.batch === batch)
588}
589
590export type Status = {
591 readonly tone: 'busy' | 'ok' | 'error'
592 readonly text: string
593}
594
595const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
596
597/** How a finished batch's work reads: what changed, and what was already so. */
598export const doneText = (work: readonly Job[]): string => {
599 const changed = work.filter(job => job.state === 'ok' && job.unchanged !== true)
600 const same = work.length - changed.length
601 const updates = work.every(job => job.kind === 'update')
602 if (updates) {
603 if (changed.length === 0) return same === 1 ? 'already up to date' : 'all already up to date'
604 return same === 0
605 ? `${changed.length} updated`
606 : `${changed.length} updated, ${same} already up to date`
607 }
608 if (changed.length === 0) return 'nothing needed changing'
609 const [only] = changed
610 if (changed.length === 1 && same === 0 && only?.target !== undefined) {
611 const name = sanitize(nameOf(only.target), { max: 40 })
612 if (only.kind === 'remove') return `${name} removed`
613 if (only.kind === 'install') return `${name} installed`
614 }
615 const applied = plural(changed.length, 'change', 'changes')
616 return same === 0 ? `${applied} applied` : `${applied} applied, ${same} already so`
617}
618
619/**
620 * The jobs a batch counts as its work: not the reload, and not a marketplace
621 * refresh (a step of an update). The pane's batch line and the summary share
622 * it, so they count a batch alike.
623 */
624export const isWork = (job: Job): boolean =>
625 job.kind !== 'reload' && job.kind !== 'marketplace-update'
626
627/**
628 * One line about the newest batch, in the pane: what runs now, or how it
629 * ended. A failure stays until the next batch; a success shows only while
630 * `showDone` (the band's echo, which the runner clears after a while: that
631 * write is what redraws the pane). A batch that changed
632 * nothing gets the same echo, though its reload never ran.
633 */
634export const batchLineOf = (queue: JobQueue, showDone: boolean): Status | undefined => {
635 const jobs = latestBatch(queue.jobs)
636 if (jobs.length === 0) return undefined
637 const running = jobs.find(job => job.state === 'running')
638 const work = jobs.filter(isWork)
639 const done = work.filter(job => job.state !== 'queued' && job.state !== 'running').length
640 if (running !== undefined) {
641 if (running.kind === 'reload') return { tone: 'busy', text: 'reloading plugins…' }
642 if (running.kind === 'marketplace-update')
643 return { tone: 'busy', text: `${describeJob(running)}…` }
644 return { tone: 'busy', text: `${describeJob(running)} (${done + 1} of ${work.length})…` }
645 }
646 if (jobs.some(job => job.state === 'queued')) {
647 return work.every(job => job.state !== 'queued')
648 ? { tone: 'busy', text: 'reload waits for the settings to settle…' }
649 : { tone: 'busy', text: `${plural(work.length, 'change', 'changes')} queued…` }
650 }
651 const failed = jobs.filter(job => job.state === 'failed' || job.state === 'interrupted')
652 if (failed.length > 0) {
653 const first = failed[0]
654 const why = first?.error?.message === undefined ? '' : `: ${first.error.message}`
655 return {
656 tone: 'error',
657 text: `${failed.length} of ${jobs.length} failed (${describeJob(first as Job)}${why})`,
658 }
659 }
660 if (!showDone) return undefined
661 const reload = jobs.find(job => job.kind === 'reload')
662 if (work.length === 0) return { tone: 'ok', text: 'plugins reloaded' }
663 const text = doneText(work)
664 return { tone: 'ok', text: reload?.state === 'ok' ? `${text}, plugins reloaded` : text }
665}
666
667/**
668 * What modmgr has to say outside the dialog, in one value: the band,
669 * the status line and the pane title are each drawn from it, so they never
670 * disagree.
671 */
672export type Summary = {
673 /** The job running now, not the reload (a refresh, a change, a test). */
674 readonly running?: Job
675 /** Changes (jobs that need a reload) queued or running: what "applying" counts. */
676 readonly pending: number
677 /** The batch's reload is running (or waiting for the turn to end). */
678 readonly reloading: boolean
679 /** A reload is owed and none is queued: `[l reload]`. */
680 readonly reloadOwed: boolean
681 readonly updates: number
682 /** Mods whose update added notable capabilities not yet seen. */
683 readonly capsCount: number
684 /** "turn-band can now run programs", when something is new. */
685 readonly caps?: string
686 /**
687 * The person dismissed the band line that said this news (updates, what an
688 * update added): the status line and the title leave it out too, until it
689 * changes. What is under way or owed is never quieted.
690 */
691 readonly newsDismissed: boolean
692 /** The CLI's answer to the last reload, or how a batch that needed none ended. */
693 readonly echo?: string
694}
695
696const updatesText = (n: number): string | undefined =>
697 n > 0 ? plural(n, 'update', 'updates') : undefined
698
699export const summaryOf = (input: {
700 readonly attention: Attention
701 readonly queue: JobQueue
702 readonly mods: readonly ModRow[]
703}): Summary => {
704 const { attention, queue, mods } = input
705 const reloadJob = queue.jobs.find(job => job.kind === 'reload' && isActive(job))
706 const running = queue.jobs.find(job => job.kind !== 'reload' && job.state === 'running')
707 const caps = capsLine(mods)
708 const news = [updatesText(attention.updates), caps].filter(
709 (part): part is string => part !== undefined,
710 )
711 const said = new Set(attention.dismissed?.split(' · ') ?? [])
712 return {
713 ...(running === undefined ? {} : { running }),
714 pending: queue.jobs.filter(job => NEEDS_RELOAD.has(job.kind) && isActive(job)).length,
715 reloading: reloadJob?.state === 'running',
716 reloadOwed: attention.reloadPending && reloadJob === undefined,
717 updates: attention.updates,
718 capsCount: mods.filter(row => row.capsNew !== undefined).length,
719 ...(caps === undefined ? {} : { caps }),
720 newsDismissed: news.length > 0 && news.every(part => said.has(part)),
721 ...(attention.lastReload === undefined ? {} : { echo: attention.lastReload }),
722 }
723}
724
725export type Band = {
726 /** What was said: a dismissal hides the band until this changes. */
727 readonly key: string
728 readonly text: string
729 /** Offer `[l reload]`: a reload is owed and none is queued. */
730 readonly reload: boolean
731}
732
733/**
734 * The band's one line: shown only when something is
735 * actionable and that very thing wasn't dismissed. `isWorking` is the band's
736 * `e.props.isWorking`: a reload asked mid-turn waits for the turn to end.
737 */
738export const bandOf = (
739 summary: Summary,
740 how: { readonly dismissed?: string | undefined; readonly isWorking: boolean },
741): Band | undefined => {
742 const parts: string[] = []
743 if (summary.running !== undefined) parts.push(`${describeJob(summary.running)}…`)
744 if (summary.reloading) {
745 parts.push(how.isWorking ? 'reload queued, runs when the turn ends' : 'reloading plugins…')
746 }
747 const updates = updatesText(summary.updates)
748 if (updates !== undefined) parts.push(updates)
749 if (summary.caps !== undefined) parts.push(summary.caps)
750 if (summary.reloadOwed) parts.push('reload to apply')
751 if (parts.length === 0) {
752 if (summary.echo === undefined) return undefined
753 parts.push(summary.echo)
754 }
755 const text = `mods · ${parts.join(' · ')}`
756 if (how.dismissed === text) return undefined
757 return { key: text, text, reload: summary.reloadOwed }
758}
759
760/**
761 * The status line under the prompt (`$.ui.status`, one per plugin, drawn
762 * `modmgr: <text>`): one clause, the most important, since it sits under
763 * every prompt. What is under way, then what is owed, then news the person
764 * hasn't dismissed (what an update added before how many updates wait);
765 * nothing when idle (undefined clears it). The band says the rest.
766 */
767export const statusLineOf = (summary: Summary): string | undefined => {
768 const { running } = summary
769 if (running?.kind === 'marketplace-update') return `${describeJob(running)}…`
770 if (summary.pending > 0) return `applying ${summary.pending}…`
771 if (running !== undefined) return `${describeJob(running)}…`
772 if (summary.reloading) return 'reloading plugins…'
773 if (summary.reloadOwed) return 'reload to apply'
774 if (summary.newsDismissed) return undefined
775 // The engine names the plugin before it (`modmgr: …`).
776 return summary.caps ?? updatesText(summary.updates)
777}
778
779/** The pane's title: `mods`, with news not dismissed (a retitle is an open). */
780export const titleOf = (summary: Summary): string => {
781 const parts = [PANE_TITLE]
782 if (!summary.newsDismissed) {
783 const updates = updatesText(summary.updates)
784 if (updates !== undefined) parts.push(updates)
785 if (summary.capsCount > 0) parts.push(`${summary.capsCount} can do more`)
786 }
787 return parts.join(' · ')
788}
789hooks/ports.ts 147 lines1// The ports services and views take in place of `$`. Types only: every
2// builder lives in register.tsx, the one file that spells `$`. Each noun
3// is its own interface, so a service asks for `Pick<Ports, 'process' | 'state'>`
4// and a test fakes only those nouns.
5
6import type {
7 CommandInfo,
8 ProcessRunResult,
9 ProcessSpawnChunk,
10 ProcessSpawnResult,
11 RenderSurface,
12 SessionRepo,
13 SessionVersion,
14 Timer,
15 UiCopyResult,
16 UiFocusResult,
17 UiOpenResult,
18 UiPane,
19} from 'claude-code'
20import type { ModmgrState, StateKey } from './domain/state.ts'
21import type { PaneOpen } from './domain/view.ts'
22
23export type { ModmgrState, StateKey }
24
25/**
26 * How a child runs. There is deliberately no `env`: children inherit the
27 * session's environment untouched (never strip `CLAUDECODE` to slip past a refusal).
28 */
29export type RunInit = { readonly cwd?: string; readonly timeoutMs?: number }
30
31export interface ProcessPort {
32 run(argv: readonly string[], init?: RunInit): Promise<ProcessRunResult>
33 /** Streams a child; leaving the loop (or `return()`) kills it. */
34 spawn(
35 argv: readonly string[],
36 init?: Pick<RunInit, 'cwd'>,
37 ): AsyncGenerator<ProcessSpawnChunk, ProcessSpawnResult>
38}
39
40/** modmgr's own `$.state` keys, read and written through shaped atoms. */
41export interface StatePort {
42 read<K extends StateKey>(key: K): Promise<ModmgrState[K]>
43 /** Compare-and-set with retry (`update` from claude-code): concurrent writers both land. */
44 update<K extends StateKey>(
45 key: K,
46 change: (value: ModmgrState[K]) => ModmgrState[K],
47 ): Promise<ModmgrState[K]>
48}
49
50export interface StorePort {
51 get(key: string): Promise<unknown>
52 /** Rejects past 4 MiB of JSON in all. */
53 set(key: string, value: unknown): Promise<void>
54 delete(key: string): Promise<void>
55 keys(): Promise<string[]>
56}
57
58export interface ClockPort {
59 now(): Promise<number>
60 after(ms: number, fn: () => void): Timer
61 every(ms: number, fn: () => void): Timer
62}
63
64/** The variables modmgr reads, one method each (`$.env.get` takes literals only). */
65export interface EnvPort {
66 pluginDirs(): Promise<string | undefined>
67 /** `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`: any non-empty value turns modmgr's network use off. */
68 nonessentialTraffic(): Promise<string | undefined>
69 /** `CLAUDE_CONFIG_DIR` and `HOME`: where this session's mods folder is (Dev). */
70 configDir(): Promise<string | undefined>
71 home(): Promise<string | undefined>
72 /** `MODMGR_INDEX_URL`: where to read the catalogue index instead, to try one before it is published. */
73 indexUrl(): Promise<string | undefined>
74}
75
76export interface SessionPort {
77 root(): Promise<string>
78 cwd(): Promise<string>
79 id(): Promise<string>
80 /** The git repository the session runs in (its `origin` remote), or null. */
81 repo(): Promise<SessionRepo | null>
82 surfaces(): Promise<readonly RenderSurface[]>
83 version(): Promise<SessionVersion>
84}
85
86export interface CommandPort {
87 /**
88 * Registers `/mods`. Spelled in register.tsx with a literal name, which keeps
89 * the `/mods` hook "answering its own command" rather than a gate.
90 */
91 registerMods(): Promise<void>
92 /** `/reload-plugins`; rejects inside a hook the turn waits on. Resolves the CLI's line. */
93 reloadPlugins(): Promise<string | undefined>
94 list(): Promise<readonly CommandInfo[]>
95}
96
97export interface UiPort {
98 /** One line to the debug log (`--debug`), never the transcript. */
99 debug(text: string): void
100 panes(): Promise<readonly UiPane[]>
101 /** Opens modmgr's pane, or retitles it and sets its manners anew when open. */
102 open(args: PaneOpen): Promise<UiOpenResult>
103 close(id: string): Promise<void>
104 /** Moves the pane's focus ring onto an element it drew (rejects or denies when it can't). */
105 focus(requestId: string, key: string): Promise<UiFocusResult>
106 copy(text: string, surface?: RenderSurface): Promise<UiCopyResult>
107 /** modmgr's one status line under the prompt; undefined clears it. */
108 status(text: string | undefined): void
109}
110
111/** The network, through the host. Used only for raw.githubusercontent.com: the catalogue index and the detector's probes. */
112export interface HttpPort {
113 /**
114 * A GET of `url` for at most `maxBytes + 1` bytes: a Range request, so a
115 * server that honours it never sends more, and a longer body is known to be
116 * too long without reading it whole. A range answered (206) reads as 200.
117 */
118 get(url: string, maxBytes: number): Promise<{ readonly status: number; readonly text: string }>
119}
120
121/** The plugin's names of a directory's entries (`$.fs.list`). */
122export type DirEntry = { readonly name: string; readonly kind: 'file' | 'dir' | 'other' }
123
124/**
125 * Reads plugin manifests and lists plugin folders: local catalogue sources
126 * (Discover) and folders of mods under development (Dev). Never writes.
127 */
128export interface FsPort {
129 /** A file's text; rejects when missing or over 4 MiB. */
130 read(path: string): Promise<string>
131 /** A directory's entries; rejects when it is missing. */
132 list(path: string): Promise<readonly DirEntry[]>
133}
134
135export type Ports = {
136 process: ProcessPort
137 state: StatePort
138 store: StorePort
139 clock: ClockPort
140 env: EnvPort
141 session: SessionPort
142 command: CommandPort
143 ui: UiPort
144 http: HttpPort
145 fs: FsPort
146}
147hooks/services/actions.ts 883 lines1// What the pane's Buttons, its Input, the band and the focus ring do:
2// each reads and writes `$.state` through the dispatch's own ports, then asks
3// the module's runtime to run what it queued (`kick`). No job ever runs here.
4// Every action catches its own failure: a press must never throw into the host.
5
6import type { RenderSurface } from 'claude-code'
7import type { DevRow, ReviewRequest, Tab, View } from '../../types/index.d.ts'
8import { type DevRunKind, devKey, devRowOf, lastRun } from '../domain/dev.ts'
9import {
10 acceptReview,
11 awaitingAcceptance,
12 communityInstallReview,
13 foundKey,
14 foundRow,
15 installReview,
16 isInstallScope,
17 MARKETPLACE_KEY,
18 marketplaceReview,
19 nextSort,
20 withScope,
21} from '../domain/discover.ts'
22import { type HealthItem, healthItemsOf, healthKey } from '../domain/health.ts'
23import {
24 enqueueReload,
25 isActive,
26 prune,
27 stillUndoes,
28 undoNeedsReview,
29 undoPlan,
30} from '../domain/jobs.ts'
31import {
32 closedView,
33 escapeStep,
34 FILTER_KEY,
35 filterRows,
36 holdsToasts,
37 neighbourOf,
38 PANE_ID,
39 paneOpen,
40 popOverlay,
41 pruneStaged,
42 pushOverlay,
43 removeReview,
44 rowKey,
45 selectedRow,
46 specsOf,
47 stagedChanges,
48 stageToggle,
49 summaryOf,
50 titleOf,
51 toggleOverlay,
52 toggleReview,
53 topOverlay,
54 undoReview,
55 updateReview,
56 whyLocked,
57 whyNoRemove,
58 whyNoUpdate,
59} from '../domain/view.ts'
60import type { Ports } from '../ports.ts'
61import { enqueue } from './job-runner.ts'
62import type { Runtime } from './runtime.ts'
63
64export type ActionPorts = Pick<Ports, 'state' | 'ui'>
65
66/** The parts of the module's runtime an action reaches; absent before the first `session.start`. */
67export type ActionRuntime = Pick<
68 Runtime,
69 | 'registry'
70 | 'showTab'
71 | 'runner'
72 | 'newJobId'
73 | 'chrome'
74 | 'catalog'
75 | 'detector'
76 | 'dev'
77 | 'health'
78 | 'updater'
79 | 'store'
80>
81
82/** What Health's "check now" says, by what the scheduler did. */
83const CHECK_SAID: Readonly<Record<'queued' | 'busy' | 'off' | 'nothing', string>> = {
84 queued: 'Checking the marketplaces for updates…',
85 busy: 'Busy now (a turn or a reload); try again in a moment',
86 off: 'Update checks are off',
87 nothing: 'No installed mod comes from a marketplace that updates',
88}
89
90export type Actions = {
91 tab(tab: Tab): Promise<void>
92 /** The focus ring landed on a row: it becomes the selection (the split's detail follows). */
93 focusRow(id: string): Promise<void>
94 /** Enter on a row: its detail. */
95 open(id: string): Promise<void>
96 /**
97 * Enter on a row whose detail is already beside the list: the ring moves onto
98 * the detail's first key. Nothing is pushed, so Esc has nothing hidden to pop.
99 */
100 toDetail(): Promise<void>
101 /** Pops the top overlay (a review popped is a review cancelled). */
102 back(): Promise<void>
103 /** Stages a toggle of the selected mod, or of `id`. */
104 toggle(id?: string): Promise<void>
105 /** `u`: reviews updating the selected mod, or `id`. */
106 update(id?: string): Promise<void>
107 /** `a`: reviews updating every mod the CLI can update. */
108 updateAll(): Promise<void>
109 /** `x`: reviews removing the selected mod, or `id`. */
110 remove(id?: string): Promise<void>
111 /** `d` on a remove's review: keep the mod's data (the default) or delete it too. */
112 keepData(): Promise<void>
113 /** Opens the review of what is staged. */
114 apply(): Promise<void>
115 /** `y` on the review: queues the batch and its reload. */
116 confirm(): Promise<void>
117 /** `n` on the review. */
118 cancel(): Promise<void>
119 /** Queues the inverse of the last batch; one that reinstalls is reviewed first. */
120 undo(): Promise<void>
121 refresh(): Promise<void>
122 /** Queues a reload on its own (the band's `[l reload]`). */
123 reload(): Promise<void>
124 overlay(which: 'help' | 'jobs'): Promise<void>
125 filter(text: string): Promise<void>
126 /** `f`: the ring onto the filter field. */
127 focusFilter(): Promise<void>
128 /** Moves the selection to the first or last row the filter shows, and the ring with it. */
129 edge(which: 'first' | 'last'): Promise<void>
130 copy(text: string, surface?: RenderSurface): Promise<void>
131 /** Opens the dialog from the band. */
132 openPane(): Promise<void>
133 /** The ring landed on a Discover row: it becomes the selection (the window follows). */
134 focusFound(id: string): Promise<void>
135 /** Enter on a Discover row: its detail (a local entry is read with `validate` first). */
136 openFound(id: string): Promise<void>
137 /** `i`: reviews installing the selected catalogue entry, or `id`. */
138 install(id?: string): Promise<void>
139 /** The install review's scope Select. */
140 scope(value: string): Promise<void>
141 /** `o`: the next sort. */
142 cycleSort(): Promise<void>
143 /** `k`: Discover lists only your marketplaces' entries, or everything again. */
144 toggleMine(): Promise<void>
145 /** `v`: reviews the declared command a stopped install or update showed. */
146 acceptShown(): Promise<void>
147 /** `m`: asks for a marketplace to add. */
148 addMarketplace(): Promise<void>
149 /** The marketplace field's Enter: reviews adding it, or says why it can't be one. */
150 submitMarketplace(text: string): Promise<void>
151 /** The footer's close: the same `ui.close` as Esc, origin `plugin`. */
152 close(): Promise<void>
153 /** Hides the band's current line. */
154 dismiss(line: string): Promise<void>
155 cancelJob(id: string): Promise<void>
156 /** The ring landed on a Dev row: it becomes Dev's selection (the split's detail follows). */
157 focusDev(key: string): Promise<void>
158 /** Enter on a Dev row: its detail. */
159 openDev(key: string): Promise<void>
160 /** `v` / `t` on Dev: validates (`--strict`) or tests the selected dev mod, or `key`. */
161 devRun(kind: DevRunKind, key?: string): Promise<void>
162 /** `p` on Dev: how to share the selected dev mod, or `key`. */
163 share(key?: string): Promise<void>
164 /** The ring landed on a Health item: it becomes Health's selection. */
165 focusHealth(key: string): Promise<void>
166 /** A Health item's fix: its button in the detail. */
167 fix(key: string): Promise<void>
168 /** Enter on a Health item, stacked: the item whole (its row is clipped), its fix a button. */
169 openHealth(key: string): Promise<void>
170 /**
171 * Esc and the close mark (`ui.close`, origin `person`): true keeps the pane
172 * open. `hadKeys` is whether the pane held the keys when it was last drawn
173 * on the terminal. Esc hands the keys back to the prompt before the hook runs
174 * is raised, so the cascade answers only when the pane had them then and has them
175 * no more; the close mark and ctrl+x x leave them with the pane and
176 * close. A kept pane re-takes the keys. `ringAway`: the ring was on a key
177 * rather than the list or its field, so this Esc brings it back to the list.
178 */
179 closing(
180 origin: 'person' | 'plugin' | 'unload',
181 hadKeys: boolean,
182 ringAway?: boolean,
183 ): Promise<boolean>
184}
185
186export const createActions = (
187 ports: ActionPorts,
188 rt: ActionRuntime | undefined,
189 debug: (text: string) => void = text => ports.ui.debug(text),
190): Actions => {
191 const { state, ui } = ports
192 const setView = (change: (view: View) => View) => state.update('view', change)
193 const notice = (text: string) => setView(view => ({ ...view, notice: text }))
194 const quiet = (view: View): View => {
195 if (view.notice === undefined) return view
196 const { notice: _gone, ...rest } = view
197 return rest
198 }
199
200 /** Runs an action, logging (never throwing) what fails. */
201 const safely =
202 <A extends unknown[]>(name: string, fn: (...args: A) => Promise<void>) =>
203 async (...args: A): Promise<void> => {
204 try {
205 await fn(...args)
206 } catch (error) {
207 debug(`modmgr: ${name} failed: ${String(error)}`)
208 }
209 }
210
211 const select = async (id: string | undefined): Promise<void> => {
212 await rt?.registry.select(id)
213 }
214
215 /**
216 * Opens (or re-opens) the dialog with its manners and the current title: a
217 * re-open sets both anew, so every open says them. Toasts are held
218 * only while nothing runs.
219 */
220 const openDialog = async (how: { focus: boolean; idle?: boolean }): Promise<void> => {
221 const [attention, queue, mods, view] = await Promise.all([
222 state.read('attention'),
223 state.read('queue'),
224 state.read('mods'),
225 state.read('view'),
226 ])
227 const idle = how.idle ?? holdsToasts(queue, view)
228 const title = titleOf(summaryOf({ attention, queue, mods }))
229 await ui.open(paneOpen({ focus: how.focus, hold: idle, mods: mods.length, title }))
230 }
231
232 /** Queues a batch and its reload; `when` guards it against a queue that moved since it was read. */
233 const queueBatch = async (
234 specs: Parameters<typeof enqueue>[1]['specs'],
235 when?: Parameters<typeof enqueue>[3],
236 ): Promise<'queued' | 'stale' | 'starting'> => {
237 if (rt === undefined) {
238 await notice('modmgr is still starting; try again in a moment')
239 return 'starting'
240 }
241 const queued = await enqueue(
242 ports,
243 { id: rt.newJobId(), specs, reload: true },
244 () => rt.newJobId(),
245 when,
246 )
247 if (!queued) return 'stale'
248 // These are the dispatch's writes, not the runtime's: say so to the status line.
249 rt.chrome.schedule()
250 // A pane left open while jobs run must not hold other plugins' toasts.
251 if ((await ui.panes()).some(pane => pane.id === PANE_ID)) {
252 await openDialog({ focus: false, idle: false })
253 }
254 rt.runner.kick()
255 return 'queued'
256 }
257
258 /** Re-takes the keys after an Esc the cascade answered (the selected row's autoFocus takes the ring). */
259 const retake = (): Promise<void> => openDialog({ focus: true })
260
261 /**
262 * Puts the ring on the first of `keys` the pane draws (a ring whose
263 * Button a redraw removed goes nowhere). `$.ui.focus` awaits an element the
264 * redraw is about to draw; a denial or a refusal tries the next key.
265 */
266 const ringTo = async (...keys: readonly string[]): Promise<void> => {
267 for (const key of keys) {
268 const moved = await ui.focus(PANE_ID, key).catch(() => ({ deny: 'refused' }))
269 if (!('deny' in moved) || moved.deny === undefined) return
270 }
271 }
272
273 /** The ring back on the selected row once no overlay is on top. */
274 const ringToSelection = async (): Promise<void> => {
275 const [view, mods, page] = await Promise.all([
276 state.read('view'),
277 state.read('mods'),
278 state.read('catalogPage'),
279 ])
280 if (view.stack.length > 0) return
281 if (view.tab === 'discover') {
282 const found = foundRow(view, page)
283 if (found !== undefined) await ringTo(foundKey(found.id))
284 return
285 }
286 if (view.tab === 'dev') {
287 const row = devRowOf(view.dev, (await state.read('dev')).rows)
288 if (row !== undefined) await ringTo(devKey(row.key))
289 return
290 }
291 if (view.tab === 'health') {
292 const items = await healthItems()
293 const item = items.find(each => each.key === view.health) ?? items[0]
294 if (item !== undefined) await ringTo(healthKey(item.key))
295 return
296 }
297 const row = selectedRow(view, mods)
298 if (row !== undefined) await ringTo(rowKey(row.id))
299 }
300
301 /**
302 * Discover's window follows its search and selection. The catalogue is module
303 * memory: a reloaded modmgr starts without it, so it is read on demand (once).
304 */
305 const showCatalog = async (): Promise<void> => {
306 if (rt === undefined) return
307 await rt.catalog.load()
308 await rt.catalog.show()
309 }
310
311 /** The ring onto what an overlay offers first: its safe default (review: cancel). */
312 const ringToOverlay = async (): Promise<void> => {
313 const view = await state.read('view')
314 const top = topOverlay(view)
315 if (top === undefined) return ringToSelection()
316 if (top === 'review') return ringTo('act:cancel')
317 if (top === 'marketplace') return ringTo(MARKETPLACE_KEY)
318 if (top === 'detail' && view.tab === 'discover') return ringTo('act:install', 'act:copy')
319 if (top === 'detail' && view.tab === 'dev') return ringTo('act:validate', 'act:copy')
320 if (top === 'detail' && view.tab === 'health') return ringTo('act:fix', 'act:back')
321 if (top === 'share') return ringTo('act:copy')
322 if (top === 'welcome') return ringTo('act:start')
323 if (top === 'detail') return ringTo('act:toggle', 'act:copy')
324 return ringTo(top === 'help' ? 'act:help' : 'act:jobs')
325 }
326
327 const dropReview = async (): Promise<void> => {
328 await state.update('review', () => null)
329 }
330
331 /** Puts a review on top, the ring on its safe default (cancel). */
332 const openReview = async (review: ReviewRequest): Promise<void> => {
333 await state.update('review', () => review)
334 await setView(current => pushOverlay(quiet(current), 'review'))
335 await ringToOverlay()
336 }
337
338 /** Health's items as the pane draws them. */
339 const healthItems = async (): Promise<HealthItem[]> => {
340 const [mods, dev, attention, degraded, sync, detect, queue, facts] = await Promise.all([
341 state.read('mods'),
342 state.read('dev'),
343 state.read('attention'),
344 state.read('degraded'),
345 state.read('sync'),
346 state.read('detect'),
347 state.read('queue'),
348 state.read('health'),
349 ])
350 return healthItemsOf({ mods, dev, attention, degraded, sync, detect, queue, facts })
351 }
352
353 /** The welcome on the stack is being left: it isn't said again. */
354 const seenWelcome = (view: View): void => {
355 if (!view.stack.includes('welcome')) return
356 rt?.store.update('prefs', prefs =>
357 prefs.firstRunDone ? prefs : { ...prefs, firstRunDone: true },
358 )
359 }
360
361 /** The tab and sort a next session opens with (the store's prefs, written in a batch). */
362 const remember = (view: View): void => {
363 rt?.store.update('prefs', prefs =>
364 prefs.tab === view.tab && prefs.sort === view.sort
365 ? prefs
366 : { ...prefs, tab: view.tab, sort: view.sort },
367 )
368 }
369
370 /** The Dev row an action names, or the selected one. */
371 const devRowFor = async (key: string | undefined): Promise<DevRow | undefined> => {
372 const [view, dev] = await Promise.all([state.read('view'), state.read('dev')])
373 return key === undefined ? devRowOf(view.dev, dev.rows) : dev.rows.find(row => row.key === key)
374 }
375
376 /** The row an action names, or the selected one. */
377 const rowFor = async (id: string | undefined) => {
378 const [view, mods] = await Promise.all([state.read('view'), state.read('mods')])
379 return id === undefined ? selectedRow(view, mods) : mods.find(item => item.id === id)
380 }
381
382 const actions: Actions = {
383 tab: safely('tab', async tab => {
384 // A review on the stack and in state go together.
385 await dropReview()
386 const view = await setView(current => ({ ...quiet(current), tab, stack: [] }))
387 remember(view)
388 await rt?.showTab(tab)
389 await ringToSelection()
390 }),
391
392 focusRow: safely('focus', async id => {
393 const view = await state.read('view')
394 if (view.selected === id) return
395 await setView(current => ({ ...quiet(current), selected: id }))
396 await select(id)
397 }),
398
399 toDetail: safely('to detail', async () => {
400 const { tab } = await state.read('view')
401 if (tab === 'discover') return ringTo('act:install', 'act:copy')
402 if (tab === 'dev') return ringTo('act:validate', 'act:copy')
403 if (tab === 'health') return ringTo('act:fix')
404 return ringTo('act:toggle', 'act:copy')
405 }),
406
407 open: safely('open', async id => {
408 await setView(view => pushOverlay({ ...quiet(view), selected: id }, 'detail'))
409 await select(id)
410 await ringToOverlay()
411 // Opening the detail is seeing what its update added.
412 await rt?.registry.acknowledge(id)
413 }),
414
415 back: safely('back', async () => {
416 const view = await state.read('view')
417 if (topOverlay(view) === 'review') await dropReview()
418 seenWelcome(view)
419 await setView(current => popOverlay(quiet(current)))
420 await ringToOverlay()
421 }),
422
423 toggle: safely('toggle', async id => {
424 const row = await rowFor(id)
425 if (row === undefined) return
426 const locked = whyLocked(row)
427 if (locked !== undefined) {
428 await notice(`${row.name}: ${locked}`)
429 return
430 }
431 await setView(current => stageToggle(quiet(current), row))
432 }),
433
434 update: safely('update', async id => {
435 const row = await rowFor(id)
436 if (row === undefined) return
437 const why = whyNoUpdate(row)
438 if (why !== undefined) {
439 await notice(`${row.name}: ${why}`)
440 return
441 }
442 await openReview(updateReview([row]))
443 }),
444
445 updateAll: safely('update all', async () => {
446 const mods = await state.read('mods')
447 const rows = mods.filter(row => whyNoUpdate(row) === undefined)
448 if (rows.length === 0) {
449 await notice('None of these mods updates through the CLI')
450 return
451 }
452 await openReview(updateReview(rows))
453 }),
454
455 remove: safely('remove', async id => {
456 const row = await rowFor(id)
457 if (row === undefined) return
458 const why = whyNoRemove(row)
459 if (why !== undefined) {
460 await notice(`${row.name}: ${why}`)
461 return
462 }
463 await openReview(removeReview(row, rt?.registry.facts(row.id)))
464 }),
465
466 keepData: safely('keep data', async () => {
467 await state.update('review', review =>
468 review?.action === 'remove' ? { ...review, keepData: review.keepData === false } : review,
469 )
470 }),
471
472 apply: safely('apply', async () => {
473 const [view, mods] = await Promise.all([state.read('view'), state.read('mods')])
474 const changes = stagedChanges(view, mods)
475 if (changes.length === 0) {
476 await setView(current => pruneStaged({ ...current, notice: 'Nothing is staged' }, mods))
477 return
478 }
479 await openReview(toggleReview(changes, id => rt?.registry.facts(id)))
480 }),
481
482 confirm: safely('confirm', async () => {
483 // Taken by compare-and-set: of two presses before the redraw (a hotkey and
484 // an Enter), one gets the review and the other finds none.
485 let taken: ReviewRequest | null = null
486 await state.update('review', review => {
487 taken = review
488 return null
489 })
490 const review = taken as ReviewRequest | null
491 if (review === null) return
492 // An undo reviewed is queued only while its batch is still the one to undo.
493 const { undoes } = review
494 const queued = await queueBatch(
495 specsOf(review),
496 undoes === undefined ? undefined : queue => stillUndoes(queue.jobs, undoes),
497 )
498 if (queued === 'starting') {
499 await state.update('review', current => current ?? review)
500 return
501 }
502 const ids = new Set(review.targets.map(target => target.id))
503 // A removed mod's detail goes with it, and the selection moves on, or the
504 // ring would land on a row the refresh is about to take away.
505 const gone = review.targets.find(target => target.op === 'remove')?.id
506 // An entry installed leaves the catalogue: its Discover detail goes too.
507 const installed = review.action === 'install' && (await state.read('view')).tab === 'discover'
508 const mods = await state.read('mods')
509 await setView(view => {
510 const staged = Object.fromEntries(
511 Object.entries(view.staged).filter(([id]) => !ids.has(id)),
512 )
513 const stack = view.stack.filter(
514 overlay =>
515 overlay !== 'review' && !((gone !== undefined || installed) && overlay === 'detail'),
516 )
517 const next = gone === undefined ? undefined : neighbourOf(view, mods, gone)
518 const moved = next === undefined ? {} : { selected: next.id }
519 const said =
520 queued === 'stale'
521 ? { notice: 'The last batch changed since this undo was shown; nothing was queued' }
522 : {}
523 return { ...quiet(view), staged, stack, ...moved, ...said }
524 })
525 if (gone !== undefined) await select((await state.read('view')).selected)
526 await ringToOverlay()
527 }),
528
529 cancel: safely('cancel', async () => {
530 await dropReview()
531 await setView(view => ({
532 ...quiet(view),
533 stack: view.stack.filter(overlay => overlay !== 'review'),
534 }))
535 await ringToOverlay()
536 }),
537
538 undo: safely('undo', async () => {
539 const [queue, mods] = await Promise.all([state.read('queue'), state.read('mods')])
540 const plan = undoPlan(queue.jobs)
541 if (plan.kind === 'none') {
542 await notice(plan.reason)
543 return
544 }
545 // A reinstall runs the mod's code again: reviewed, as an install is.
546 if (undoNeedsReview(plan)) {
547 await openReview(undoReview(plan, mods, id => rt?.registry.facts(id)))
548 return
549 }
550 // Two presses read one queue: only the first still finds this batch to undo.
551 const queued = await queueBatch(
552 plan.steps.map(step => step.spec),
553 current => stillUndoes(current.jobs, plan.batch),
554 )
555 if (queued === 'queued') await notice(`Undoing the last batch (${plan.steps.length})`)
556 }),
557
558 refresh: safely('refresh', async () => {
559 if (rt === undefined) return
560 const { tab } = await state.read('view')
561 if (tab === 'dev' || tab === 'health') {
562 await rt.registry.refresh()
563 await rt.showTab(tab)
564 return
565 }
566 if (tab === 'discover') {
567 await rt.catalog.load({ force: true })
568 rt.detector.start()
569 return
570 }
571 await rt.registry.refresh()
572 // A row the refresh flipped under a staged entry no longer changes.
573 const mods = await state.read('mods')
574 await setView(view => pruneStaged(view, mods))
575 }),
576
577 reload: safely('reload', async () => {
578 if (rt === undefined) return
579 const batch = rt.newJobId()
580 const id = rt.newJobId()
581 await state.update('queue', queue => ({
582 ...queue,
583 jobs: prune(enqueueReload(queue.jobs, id, batch)),
584 }))
585 rt.chrome.schedule()
586 rt.runner.kick()
587 }),
588
589 overlay: safely('overlay', async which => {
590 await setView(view => toggleOverlay(quiet(view), which))
591 await ringToOverlay()
592 }),
593
594 filter: safely('filter', async text => {
595 if ((await state.read('view')).tab === 'discover') {
596 await setView(current => ({ ...quiet(current), search: text.slice(0, 100) }))
597 await showCatalog()
598 return
599 }
600 const view = await setView(current => ({ ...quiet(current), query: text.slice(0, 100) }))
601 // The split's detail follows the row the filter leaves selected.
602 await select(selectedRow(view, await state.read('mods'))?.id)
603 }),
604
605 focusFilter: safely('focus filter', async () => {
606 await ui.focus(PANE_ID, FILTER_KEY).catch(() => undefined)
607 }),
608
609 edge: safely('edge', async which => {
610 const { tab } = await state.read('view')
611 if (tab === 'dev') {
612 const { rows } = await state.read('dev')
613 const row = which === 'first' ? rows[0] : rows.at(-1)
614 if (row === undefined) return
615 await setView(current => ({ ...quiet(current), dev: row.key }))
616 await ui.focus(PANE_ID, devKey(row.key)).catch(() => undefined)
617 return
618 }
619 if (tab === 'discover') {
620 await rt?.catalog.load()
621 const id = rt?.catalog.edge(which)
622 if (id === undefined) return
623 await setView(current => ({ ...quiet(current), found: id }))
624 await showCatalog()
625 await ui.focus(PANE_ID, foundKey(id)).catch(() => undefined)
626 return
627 }
628 const [view, mods] = await Promise.all([state.read('view'), state.read('mods')])
629 const rows = filterRows(mods, view.query)
630 const row = which === 'first' ? rows[0] : rows.at(-1)
631 if (row === undefined) return
632 await setView(current => ({ ...quiet(current), selected: row.id }))
633 await select(row.id)
634 // The row may be drawn only after the redraw this write causes; focus awaits it.
635 await ui.focus(PANE_ID, rowKey(row.id)).catch(() => undefined)
636 }),
637
638 copy: safely('copy', async (text, surface) => {
639 const copied = await ui.copy(text, surface)
640 await notice(copied.isCopied ? `Copied ${text}` : `Couldn't copy: ${copied.reason}`)
641 }),
642
643 openPane: safely('open pane', async () => {
644 await openDialog({ focus: true })
645 // A restored tab is read when the dialog shows it.
646 void rt?.showTab((await state.read('view')).tab)
647 }),
648
649 focusFound: safely('focus found', async id => {
650 const view = await state.read('view')
651 if (view.found === id) return
652 await setView(current => ({ ...quiet(current), found: id }))
653 await showCatalog()
654 // A local entry beside the list (the split) says what it can do too: read in
655 // the background, never on the ring's path; the redraw follows.
656 void rt?.catalog.inspect(id)
657 }),
658
659 openFound: safely('open found', async id => {
660 await setView(view => pushOverlay({ ...quiet(view), found: id }, 'detail'))
661 await showCatalog()
662 await ringToOverlay()
663 // A local entry is read now: the detail redraws when it lands.
664 void rt?.catalog.inspect(id)
665 }),
666
667 install: safely('install', async id => {
668 if (rt === undefined) return
669 await rt.catalog.load()
670 const [view, page] = await Promise.all([state.read('view'), state.read('catalogPage')])
671 const found = id ?? foundRow(view, page)?.id
672 const mod = found === undefined ? undefined : rt.catalog.mod(found)
673 if (mod !== undefined) {
674 const review = communityInstallReview(mod, 'user')
675 if (review === undefined) {
676 await notice(`No marketplace lists ${mod.name}: c copies its link`)
677 return
678 }
679 await openReview(review)
680 return
681 }
682 const entry = found === undefined ? undefined : rt.catalog.entry(found)
683 if (entry === undefined) {
684 await notice(
685 found === undefined ? 'Select an entry to install' : `${found} is no longer listed`,
686 )
687 return
688 }
689 const inspection = await rt.catalog.inspect(entry.id)
690 await openReview(installReview(entry, 'user', inspection))
691 }),
692
693 scope: safely('scope', async value => {
694 if (!isInstallScope(value)) return
695 await state.update('review', review => (review === null ? null : withScope(review, value)))
696 }),
697
698 cycleSort: safely('sort', async () => {
699 remember(await setView(view => ({ ...quiet(view), sort: nextSort(view.sort) })))
700 await showCatalog()
701 }),
702
703 toggleMine: safely('mine', async () => {
704 await setView(view => ({ ...quiet(view), mine: view.mine !== true }))
705 await showCatalog()
706 }),
707
708 acceptShown: safely('accept', async () => {
709 const queue = await state.read('queue')
710 const stopped = awaitingAcceptance(queue.jobs)
711 const review = stopped === undefined ? undefined : acceptReview(stopped)
712 if (review === undefined) {
713 await notice('Nothing waits for a command to be reviewed')
714 return
715 }
716 await openReview(review)
717 }),
718
719 addMarketplace: safely('add marketplace', async () => {
720 await setView(view => pushOverlay(quiet(view), 'marketplace'))
721 await ringToOverlay()
722 }),
723
724 submitMarketplace: safely('submit marketplace', async text => {
725 const asked = marketplaceReview(text)
726 if ('error' in asked) {
727 await notice(asked.error)
728 return
729 }
730 await setView(view => ({
731 ...view,
732 stack: view.stack.filter(overlay => overlay !== 'marketplace'),
733 }))
734 await openReview(asked.review)
735 }),
736
737 close: safely('close', async () => {
738 await ui.close(PANE_ID)
739 }),
740
741 dismiss: safely('dismiss', async line => {
742 await state.update('attention', attention => ({ ...attention, dismissed: line }))
743 // A dismissal quiets the status line and the title too.
744 rt?.chrome.schedule()
745 }),
746
747 cancelJob: safely('cancel job', async id => {
748 await rt?.runner.cancel(id)
749 }),
750
751 focusDev: safely('focus dev', async key => {
752 const view = await state.read('view')
753 if (view.dev === key) return
754 await setView(current => ({ ...quiet(current), dev: key }))
755 }),
756
757 openDev: safely('open dev', async key => {
758 await setView(view => pushOverlay({ ...quiet(view), dev: key }, 'detail'))
759 await ringToOverlay()
760 }),
761
762 devRun: safely('dev run', async (kind, key) => {
763 const row = await devRowFor(key)
764 if (row === undefined) return
765 const { path } = row
766 // One validate (or test) of a folder at a time: a second press says so.
767 const running = lastRun((await state.read('queue')).jobs, kind, path)
768 if (running !== undefined && isActive(running)) {
769 await notice(
770 `${row.name}: ${kind === 'test' ? 'its tests are' : 'it is being validated'} already`,
771 )
772 return
773 }
774 await queueBatch(
775 [{ kind, target: row.id ?? row.name, args: { path } }],
776 queue =>
777 !queue.jobs.some(job => isActive(job) && job.kind === kind && job.args?.path === path),
778 )
779 }),
780
781 share: safely('share', async key => {
782 const row = await devRowFor(key)
783 if (row === undefined || rt === undefined) return
784 if ((await rt.dev.share(row.key)) === undefined) return
785 await setView(view => pushOverlay({ ...quiet(view), dev: row.key }, 'share'))
786 await ringToOverlay()
787 }),
788
789 openHealth: safely('open health', async key => {
790 await setView(view => pushOverlay({ ...quiet(view), health: key }, 'detail'))
791 await ringToOverlay()
792 }),
793
794 focusHealth: safely('focus health', async key => {
795 const view = await state.read('view')
796 if (view.health === key) return
797 await setView(current => ({ ...quiet(current), health: key }))
798 }),
799
800 fix: safely('fix', async key => {
801 const item = (await healthItems()).find(each => each.key === key)
802 const fix = item?.fix
803 if (fix === undefined || rt === undefined) return
804 // The item's detail, when it was open, has done its job.
805 await setView(current => ({
806 ...quiet(current),
807 health: key,
808 stack: current.stack.filter(overlay => overlay !== 'detail'),
809 }))
810 switch (fix.kind) {
811 case 'open':
812 // What a mod can do, and its errors, are in its Installed detail.
813 await setView(view => ({ ...view, tab: 'installed', stack: [] }))
814 await actions.open(fix.id)
815 return
816 case 'update':
817 await actions.update(fix.id)
818 return
819 case 'validate':
820 await actions.devRun('validate', fix.key)
821 return
822 case 'copy':
823 await actions.copy(fix.text)
824 return
825 case 'reload':
826 await actions.reload()
827 return
828 case 'refresh':
829 await actions.refresh()
830 return
831 case 'clear-cache': {
832 const cleared = await rt.store.clearCaches()
833 await notice(
834 cleared.ok ? 'Cache cleared' : `Couldn't clear the cache: ${cleared.error.message}`,
835 )
836 await rt.health.refresh()
837 return
838 }
839 case 'check-updates': {
840 const outcome = await rt.updater.run()
841 await notice(CHECK_SAID[outcome])
842 await rt.health.refresh()
843 return
844 }
845 }
846 }),
847
848 async closing(origin, hadKeys, ringAway = false) {
849 try {
850 const stillHasKeys =
851 (await ui.panes()).find(pane => pane.id === PANE_ID)?.isFocused === true
852 debug(`modmgr: close from ${origin}, keys at last draw ${hadKeys}, now ${stillHasKeys}`)
853 const before = await state.read('view')
854 // Closed or popped, the welcome was on screen: seen.
855 seenWelcome(before)
856 if (origin === 'person' && hadKeys && !stillHasKeys) {
857 const view = before
858 const step = escapeStep(view, true, ringAway)
859 if (step.kind !== 'close') {
860 if (step.kind === 'pop' && topOverlay(view) === 'review') await dropReview()
861 if (step.kind !== 'to-list') {
862 await setView(current => {
863 const now = escapeStep(current, true, ringAway)
864 return now.kind === 'pop' || now.kind === 'clear-query' ? quiet(now.view) : current
865 })
866 }
867 if (step.kind === 'clear-query') await showCatalog()
868 await retake()
869 await ringToOverlay()
870 return true
871 }
872 }
873 await dropReview()
874 await setView(view => closedView(view))
875 } catch (error) {
876 debug(`modmgr: close failed: ${String(error)}`)
877 }
878 return false
879 },
880 }
881 return actions
882}
883hooks/services/commands.ts 318 lines1// `/mods`: the bare command opens the dialog; the
2// subcommands answer as text, for a script, a `-p` run or a session that
3// places no panes. A write asks for `--yes`, then runs its jobs here, on this
4// hook's own ports, through the runner's own `runJob` (the same checks and
5// declared-command handling), and records them on the queue and in the history.
6// It never reloads (a reload asked from a command hook rejects); it ends
7// with how to apply what changed.
8
9import type { Job, ModRow } from '../../types/index.d.ts'
10import { parseModsArgs, USAGE } from '../domain/command-args.ts'
11import {
12 type ApplyStep,
13 applyPlan,
14 doctorText,
15 exportOf,
16 infoText,
17 jobsText,
18 listText,
19 whyNot,
20} from '../domain/command-text.ts'
21import { healthItemsOf } from '../domain/health.ts'
22import {
23 appendTail,
24 finish,
25 isActive,
26 type JobSpec,
27 NEEDS_RELOAD,
28 prune,
29 start,
30} from '../domain/jobs.ts'
31import { sanitize } from '../domain/sanitize.ts'
32import {
33 commandLine,
34 paneOpen,
35 removeReview,
36 specsOf,
37 summaryOf,
38 titleOf,
39 toggleReview,
40 updateReview,
41} from '../domain/view.ts'
42import type { Ports } from '../ports.ts'
43import { historyOf, runJob } from './job-runner.ts'
44import type { Runtime } from './runtime.ts'
45
46export type CommandAnswer = { text?: string; exitCode?: number }
47
48export type CommandPorts = Pick<Ports, 'state' | 'ui' | 'fs' | 'clock' | 'process' | 'session'>
49
50/** The parts of the runtime a subcommand reaches; absent before the first `session.start`. */
51export type CommandRuntime = Pick<
52 Runtime,
53 | 'registry'
54 | 'newJobId'
55 | 'health'
56 | 'dev'
57 | 'chrome'
58 | 'store'
59 | 'jobFinished'
60 | 'runner'
61 | 'owner'
62 | 'showTab'
63>
64
65export { USAGE }
66
67export const modsCommand = async (
68 ports: CommandPorts,
69 rt: CommandRuntime | undefined,
70 args: string,
71): Promise<CommandAnswer> => {
72 const parsed = parseModsArgs(args)
73 if (!parsed.ok) return { text: `${parsed.error.message}\n\n${USAGE}`, exitCode: 2 }
74 const command = parsed.value
75 if (command.kind === 'help') return { text: USAGE }
76 if (command.kind === 'open' && (await openDialog(ports))) {
77 // A restored tab is read when the dialog shows it; not awaited (waiting on
78 // anything but its own `$` calls runs down the hook's 10 s budget).
79 if (rt !== undefined) void rt.showTab((await ports.state.read('view')).tab)
80 return {}
81 }
82 if (rt === undefined)
83 return { text: 'modmgr is still starting; try again in a moment.', exitCode: 1 }
84 // Read when start-up hasn't yet, its CLI on this hook's own ports (their `$`
85 // calls don't run down the hook's 10 s budget).
86 if (!rt.registry.isLoaded()) await rt.registry.refresh(ports)
87 const [mods, sync, degraded] = await Promise.all([
88 ports.state.read('mods'),
89 ports.state.read('sync'),
90 ports.state.read('degraded'),
91 ])
92 // Said first only when it matters to every answer: the CLI is missing (not the network note).
93 const said =
94 degraded.process && degraded.reason !== undefined
95 ? [sanitize(degraded.reason, { max: 300 })]
96 : []
97 const answer = (text: string, exitCode = 0): CommandAnswer =>
98 exitCode === 0
99 ? { text: [...said, text].join('\n') }
100 : { text: [...said, text].join('\n'), exitCode }
101 const rowOf = (id: string): ModRow | undefined => mods.find(row => row.id === id)
102 if (!rt.registry.isLoaded()) {
103 const why = sync.error?.message ?? 'the claude CLI did not answer'
104 return answer(`Couldn't read your plugins: ${sanitize(why, { max: 300 })}`, 1)
105 }
106
107 switch (command.kind) {
108 case 'open':
109 case 'list':
110 return answer(listText(mods, { skipped: sync.skipped }))
111 case 'info': {
112 const detail = rt.registry.detail(command.id)
113 if (detail === undefined) return answer(`${command.id} is not an installed mod.`, 1)
114 const row = rowOf(command.id)
115 return answer(
116 infoText(row?.updateTo === undefined ? detail : { ...detail, updateTo: row.updateTo }),
117 )
118 }
119 case 'doctor': {
120 // Health's facts are local reads (a few ms); Dev's rows aren't needed for its items.
121 await rt.health.refresh()
122 const [dev, attention, detect, queue, facts] = await Promise.all([
123 ports.state.read('dev'),
124 ports.state.read('attention'),
125 ports.state.read('detect'),
126 ports.state.read('queue'),
127 ports.state.read('health'),
128 ])
129 const items = healthItemsOf({ mods, dev, attention, degraded, sync, detect, queue, facts })
130 const problems = items.some(item => item.tone === 'bad')
131 return { text: doctorText(items, command.json), ...(problems ? { exitCode: 1 } : {}) }
132 }
133 case 'export':
134 return { text: JSON.stringify(exportOf(mods), null, 2) }
135 }
136
137 // The rest change something: they need the CLI, and `--yes`.
138 if (degraded.process) return answer('Changing mods needs the claude CLI.', 1)
139 let specs: JobSpec[]
140 switch (command.kind) {
141 case 'install':
142 specs = [
143 {
144 kind: 'install',
145 target: command.id,
146 args: {
147 scope: command.scope,
148 ...(command.acceptSha === undefined ? {} : { acceptSha: command.acceptSha }),
149 },
150 },
151 ]
152 break
153 case 'remove':
154 case 'enable':
155 case 'disable': {
156 const row = rowOf(command.id)
157 if (row === undefined) return answer(`${command.id} is not an installed mod.`, 1)
158 const why = whyNot(command.kind, row)
159 if (why !== undefined) return answer(`${sanitize(row.name, { max: 40 })}: ${why}`, 1)
160 if (command.kind === 'remove') {
161 const review = removeReview(row, rt.registry.facts(row.id))
162 specs = specsOf({ ...review, keepData: !command.wipe })
163 break
164 }
165 const enable = command.kind === 'enable'
166 if (row.enabled === enable)
167 return answer(`${sanitize(row.name, { max: 40 })} is already ${enable ? 'on' : 'off'}.`)
168 specs = specsOf(toggleReview([{ row, enable }], id => rt.registry.facts(id)))
169 break
170 }
171 case 'update': {
172 const rows =
173 command.id === undefined ? mods.filter(row => whyNot('update', row) === undefined) : []
174 if (command.id !== undefined) {
175 const row = rowOf(command.id)
176 if (row === undefined) return answer(`${command.id} is not an installed mod.`, 1)
177 const why = whyNot('update', row)
178 if (why !== undefined) return answer(`${sanitize(row.name, { max: 40 })}: ${why}`, 1)
179 rows.push(row)
180 }
181 if (rows.length === 0) return answer('None of these mods updates through the CLI.')
182 specs = specsOf(updateReview(rows))
183 break
184 }
185 case 'apply': {
186 const text = await ports.fs.read(command.file).catch((error: unknown) => {
187 return { error: error instanceof Error ? error.message : String(error) }
188 })
189 if (typeof text !== 'string') {
190 return answer(
191 `Couldn't read ${sanitize(command.file, { max: 200 })}: ${sanitize(text.error, { max: 200 })}`,
192 1,
193 )
194 }
195 const plan = applyPlan(text, mods)
196 // A file it can't use is a refused run, not a usage error.
197 if (plan.errors.length > 0) return answer(plan.errors.join('\n'), 1)
198 if (plan.steps.length === 0) return answer(`Nothing to do: ${plan.already} already so.`)
199 specs = plan.steps.map(stepSpec)
200 break
201 }
202 }
203 if (!command.yes) {
204 const lines = specs.map(spec => ` ${commandLine(spec) ?? `${spec.kind} ${spec.target ?? ''}`}`)
205 return answer(['This would run:', ...lines, 'Add --yes to run it.'].join('\n'), 1)
206 }
207 const jobs = await runWrites(ports, rt, specs)
208 if (jobs === 'busy') {
209 return answer(
210 'A job is running (the job log, j in /mods, shows it); try again when it ends.',
211 1,
212 )
213 }
214 if (jobs === 'moved') return answer('modmgr was reloaded meanwhile; run the command again.', 1)
215 const outcome = jobsText(jobs)
216 return answer(outcome.text, outcome.failed ? 1 : 0)
217}
218
219const stepSpec = (step: ApplyStep): JobSpec =>
220 step.kind === 'install'
221 ? { kind: 'install', target: step.id, args: { scope: step.scope } }
222 : step.scope === undefined
223 ? { kind: 'enable', target: step.id }
224 : { kind: 'enable', target: step.id, args: { scope: step.scope } }
225
226/**
227 * Runs `specs` as one batch on the queue, like the dialog's: refused while a
228 * job is queued or running or another module owns the queue; otherwise each
229 * job is `running` on the queue while it runs (the runner sees it and waits, a
230 * queued reload waits behind it), finished and the next started
231 * in one write. The CLI runs on this command hook's own ports, whose `$` calls
232 * its 10 s budget doesn't run through. No reload (it would reject in a command hook).
233 */
234const runWrites = async (
235 ports: CommandPorts,
236 rt: CommandRuntime,
237 specs: readonly JobSpec[],
238): Promise<Job[] | 'busy' | 'moved'> => {
239 const batch = rt.newJobId()
240 const planned: Job[] = specs.map(spec => ({
241 id: rt.newJobId(),
242 batch,
243 kind: spec.kind,
244 state: 'queued',
245 tail: [],
246 ...(spec.target === undefined ? {} : { target: spec.target }),
247 ...(spec.args === undefined ? {} : { args: spec.args }),
248 }))
249 const ids = new Set(planned.map(job => job.id))
250 const startedAt = await ports.clock.now()
251 let refused: 'busy' | 'moved' | undefined
252 await ports.state.update('queue', queue => {
253 refused = queue.owner !== rt.owner ? 'moved' : queue.jobs.some(isActive) ? 'busy' : undefined
254 if (refused !== undefined) return queue
255 const jobs = [...queue.jobs, ...planned]
256 const first = planned[0]
257 return { ...queue, jobs: prune(first === undefined ? jobs : start(jobs, first.id, startedAt)) }
258 })
259 if (refused !== undefined) return refused
260 rt.chrome.schedule()
261 for (const [index, job] of planned.entries()) {
262 const outcome = await runJob(ports, job, async id => rt.registry.entry(id) !== undefined)
263 const endedAt = await ports.clock.now()
264 const next = planned[index + 1]
265 let owned = false
266 await ports.state.update('queue', queue => {
267 owned = queue.owner === rt.owner
268 // A newer module took the queue over mid-write: it has settled this batch.
269 if (!owned) return queue
270 const finished = finish(
271 appendTail(queue.jobs, job.id, outcome.tail),
272 job.id,
273 endedAt,
274 outcome.finish,
275 )
276 return {
277 ...queue,
278 jobs: prune(next === undefined ? finished : start(finished, next.id, endedAt)),
279 }
280 })
281 if (!owned) break
282 rt.store.update('history', history => [...history, historyOf(job, endedAt, outcome)])
283 }
284 const jobs = (await ports.state.read('queue')).jobs.filter(job => ids.has(job.id))
285 if (
286 jobs.some(job => NEEDS_RELOAD.has(job.kind) && job.state === 'ok' && job.unchanged !== true)
287 ) {
288 await ports.state.update('attention', attention => ({ ...attention, reloadPending: true }))
289 }
290 // A -p run exits with the answer: the history is written before it.
291 await rt.store.flush()
292 for (const job of jobs) rt.jobFinished(job)
293 rt.chrome.schedule()
294 void rt.registry.refresh()
295 // A reload that waited behind the batch goes on.
296 rt.runner.kick()
297 return jobs
298}
299
300/** Opens the dialog; false when this session places no panes (a `-p` run, an older host). */
301const openDialog = async (ports: Pick<Ports, 'state' | 'ui'>): Promise<boolean> => {
302 try {
303 const [mods, queue, attention] = await Promise.all([
304 ports.state.read('mods'),
305 ports.state.read('queue'),
306 ports.state.read('attention'),
307 ])
308 const busy = queue.jobs.some(isActive)
309 const title = titleOf(summaryOf({ attention, queue, mods }))
310 const opened = await ports.ui.open(
311 paneOpen({ focus: true, hold: !busy, mods: mods.length, title }),
312 )
313 return opened.isPlaced
314 } catch {
315 return false
316 }
317}
318hooks/services/lifecycle.ts 126 lines1// The ordered fan-out for events modmgr hooks once (a module may hook an event
2// only once without a matcher). Sequencing
3// lives here, not in register.tsx, so it is tested.
4//
5// `session.start` must stay under 5 ms of blocking work: it registers
6// `/mods` and takes the job queue over, then hands everything else to a clock
7// timer. It runs again when modmgr's own module reloads, so every step is
8// idempotent.
9
10import { RELOADED_WITH_MODMGR, takeOver, tookOverReload } from '../domain/jobs.ts'
11import { readPrefs } from '../domain/store-schema.ts'
12import { PANE_ID } from '../domain/view.ts'
13import { probeAndRecord } from './capability-probe.ts'
14import { echoLine } from './job-runner.ts'
15import type { Runtime } from './runtime.ts'
16
17export const MODS_DESCRIPTION = 'Discover, inspect, toggle and update mods'
18
19export const onSessionStart = async (rt: Runtime): Promise<void> => {
20 const { ports } = rt
21 let fresh = false
22 let reloaded = false
23 // Side by side: each is a host round trip, and session.start blocks the session.
24 const takeQueue = async (): Promise<void> => {
25 const now = await ports.clock.now()
26 await ports.state.update('queue', queue => {
27 fresh = queue.owner === ''
28 const next = takeOver(queue, rt.owner, now)
29 reloaded = tookOverReload(queue, next)
30 return next
31 })
32 }
33 await Promise.all([ports.command.registerMods(), takeQueue()])
34 // The reload that restarted this module applied what the batch changed.
35 if (reloaded) {
36 await ports.state.update('attention', attention => ({ ...attention, reloadPending: false }))
37 // Said for a while, as the runner's own echo is.
38 await echoLine(ports, RELOADED_WITH_MODMGR)
39 }
40 ports.clock.after(0, () => {
41 void background(rt, { fresh })
42 })
43}
44
45/**
46 * The rest of start-up, off the blocking path: the store, preferences (first
47 * start of the session only; a reloaded module keeps the view it finds), the
48 * capability probe, the installed list, and any queued jobs.
49 */
50export const background = async (rt: Runtime, how: { fresh: boolean }): Promise<void> => {
51 const { ports } = rt
52 try {
53 await rt.store.load()
54 if (how.fresh) {
55 const prefs = readPrefs(rt.store.get('prefs'))
56 // Until it has been seen, a session that draws opens on a word of welcome;
57 // leaving it marks it seen. A `-p` run draws nothing.
58 const draws = (await ports.session.surfaces().catch(() => [])).length > 0
59 const welcome = draws && !prefs.firstRunDone
60 await ports.state.update('view', view => ({
61 ...view,
62 tab: prefs.tab,
63 sort: prefs.sort,
64 ...(welcome ? { stack: ['welcome' as const] } : {}),
65 }))
66 }
67 // A reloaded module says again what the last one left on the status line.
68 await rt.chrome.sync()
69 const probe = await probeAndRecord(ports)
70 if (!probe.process) {
71 await rt.registry.refresh()
72 rt.runner.kick()
73 // Re-armed by every module from the stored time: a reload loses timers.
74 await rt.updater.arm()
75 // A reloaded modmgr with a tab showing reads what it needs again (module memory). A
76 // fresh session only restores the tab: it is read when the dialog opens, or now
77 // when `/mods` opened it before this module was ready to read it.
78 const shown = (await ports.ui.panes().catch(() => [])).some(
79 pane => pane.id === PANE_ID && pane.isPlaced,
80 )
81 if (!how.fresh || shown) await rt.showTab((await ports.state.read('view')).tab)
82 }
83 } catch (error) {
84 ports.ui.debug(`modmgr: start-up failed: ${String(error)}`)
85 }
86}
87
88/**
89 * The turns running now, by id: a subagent's run raises no
90 * `turn.start` and its `turn.complete` carries `agentId` (d.ts TurnCompleteFields),
91 * so only the main loop's own start and end count. The detector probes, and
92 * the update scheduler fetches, only while none runs.
93 */
94export const onTurnStart = (rt: Runtime | undefined, turnId: string): void => {
95 if (rt === undefined) return
96 rt.turns.add(turnId)
97 rt.detector.setBusy(true)
98}
99
100export const onTurnEnd = (
101 rt: Runtime | undefined,
102 turnId: string,
103 agentId: string | undefined,
104): void => {
105 if (rt === undefined || agentId !== undefined) return
106 rt.turns.delete(turnId)
107 rt.detector.setBusy(rt.turns.size > 0)
108}
109
110/**
111 * A notice the session appended (`session.append`, door `notice`): while it
112 * hot-reloads a folder, the engine says there when a plugin's hook or module
113 * failed. Dev counts them; nothing is answered or changed.
114 */
115export const onNotice = (
116 rt: Runtime | undefined,
117 content: readonly { readonly type: string; readonly [field: string]: unknown }[],
118): void => {
119 if (rt === undefined) return
120 for (const block of content) {
121 if (block.type === 'text' && typeof block.text === 'string') {
122 void rt.dev.notice(block.text).catch(() => undefined)
123 }
124 }
125}
126hooks/services/runtime.ts 203 lines1// The long-lived services of one module instance: built once
2// at its first `session.start`, on that dispatch's ports, and kept in
3// register.tsx's module scope. A reload of modmgr builds a new one; the old
4// one's in-flight work finishes on its own and then stops (it no longer owns
5// the queue).
6
7import type { Job, Tab } from '../../types/index.d.ts'
8import { type Config, trafficOff } from '../domain/config.ts'
9import type { StateKey } from '../domain/state.ts'
10import type { Ports, StatePort } from '../ports.ts'
11import { type Catalog, createCatalog } from './catalog.ts'
12import { createIndexSync } from './catalog-index.ts'
13import { type Chrome, createChrome } from './chrome.ts'
14import { createCommunity } from './community.ts'
15import { createDetector, DETECT_BUDGET, type Detector } from './detector.ts'
16import { createDev, type Dev } from './dev.ts'
17import { createHealth, type Health } from './health.ts'
18import { createRunner, type Runner } from './job-runner.ts'
19import { createRegistry, type Registry } from './registry.ts'
20import { createStore, type StoreService } from './store.ts'
21import { type Timing, timingOf } from './timing.ts'
22import { createUpdater, type Updater } from './updates.ts'
23
24export type Runtime = {
25 /** This module's queue owner id. */
26 readonly owner: string
27 readonly ports: Ports
28 readonly config: Config
29 readonly store: StoreService
30 readonly registry: Registry
31 readonly runner: Runner
32 /** The status line and the pane title. */
33 readonly chrome: Chrome
34 /** Discover's catalogue (module memory). */
35 readonly catalog: Catalog
36 /** Finds which catalogue entries are mods, while no turn runs. */
37 readonly detector: Detector
38 /** Dev's mods under development and their failures. */
39 readonly dev: Dev
40 /** Checks for updates every `updateCheckHours`, while idle. */
41 readonly updater: Updater
42 /** Health's facts beyond the other keys. */
43 readonly health: Health
44 /** Says how long a step took, with `debugTimings` on (`docs/PERF.md`). */
45 readonly timing: Timing
46 /** After a job ends (the runner's, or a text write's): catalogue, analyses, updates. */
47 jobFinished(job: Job): void
48 /**
49 * What a tab needs read when it is shown (a press, an open, a reloaded module
50 * with it showing): Discover's catalogue and the detector, Dev's folders,
51 * Health's facts. Never at a fresh start.
52 */
53 showTab(tab: Tab): Promise<void>
54 /** The main loop's turns running now, by id (lifecycle `onTurnStart`/`onTurnEnd`). */
55 readonly turns: Set<string>
56 /** Job ids unique across modules: the owner, then a counter. */
57 newJobId(): string
58 dispose(): void
59}
60
61/** The keys the status line and the title are drawn from (domain/view.ts `summaryOf`). */
62const SUMMARY_KEYS: ReadonlySet<StateKey> = new Set(['attention', 'queue', 'mods'])
63
64/** A state port that calls `written` after each successful write of `keys`. */
65export const observedState = (
66 state: StatePort,
67 keys: ReadonlySet<StateKey>,
68 written: () => void,
69): StatePort => ({
70 read: key => state.read(key),
71 async update(key, change) {
72 const value = await state.update(key, change)
73 if (keys.has(key)) written()
74 return value
75 },
76})
77
78export const createRuntime = (base: Ports, config: Config, owner: string): Runtime => {
79 const debug = (text: string): void => base.ui.debug(text)
80 const timing = timingOf(config.debugTimings, debug)
81 const chrome = createChrome(base, debug)
82 // The runtime's own writes (the runner's, the registry's) keep the status line current.
83 const ports: Ports = {
84 ...base,
85 state: observedState(base.state, SUMMARY_KEYS, () => chrome.schedule()),
86 }
87 const store = createStore(ports, { debug })
88 const registry = createRegistry(ports, store, debug, timing)
89 const trafficIsOff = async (): Promise<boolean> =>
90 trafficOff(await ports.env.nonessentialTraffic().catch(() => undefined))
91 const remoteAllowed = async (): Promise<boolean> => config.detectRemote && !(await trafficIsOff())
92 const community = createCommunity(ports, { allowed: remoteAllowed, debug })
93 const catalog = createCatalog(ports, store, debug, timing, community)
94 const detector = createDetector(ports, {
95 store,
96 catalog,
97 index: createIndexSync(ports, { store, debug }),
98 debug,
99 remoteAllowed,
100 })
101 /** A marketplace or an install changes what the catalogue lists. */
102 const recatalog = (): void => {
103 if (!catalog.isLoaded()) return
104 void catalog.load({ force: true }).then(() => detector.start())
105 }
106 /** After a job ends, the runner's or a text write's: what it changed is read again. */
107 const jobFinished = (job: Job): void => {
108 const lists = ['install', 'remove', 'marketplace-add', 'marketplace-update']
109 if (job.state === 'ok' && lists.includes(job.kind)) recatalog()
110 // A folder validated anew: Installed reads what it can do again (it may have changed
111 // without a new version).
112 const path = job.args?.path
113 if (job.kind === 'validate' && path !== undefined && registry.forget(path)) {
114 void registry.refresh()
115 }
116 // A refreshed marketplace says what can update.
117 if (job.kind === 'marketplace-update' && job.state === 'ok') void updater.check()
118 // The CLI found it current: what a check guessed goes, before the refresh that follows.
119 if (job.kind === 'update' && job.unchanged === true && job.target !== undefined) {
120 updater.forget(job.target)
121 }
122 }
123 const runner = createRunner(ports, {
124 owner,
125 store,
126 isInstalled: async id => {
127 if (!registry.isLoaded()) await registry.refresh()
128 return registry.entry(id) !== undefined
129 },
130 onSettled: () => registry.refresh(),
131 onFinished: job => jobFinished(job),
132 debug,
133 })
134 const dev = createDev(ports, registry, debug, timing)
135 let counter = 0
136 const newJobId = (): string => {
137 counter += 1
138 return `${owner}-${counter}`
139 }
140 const turns = new Set<string>()
141 const updater = createUpdater(ports, {
142 store,
143 registry,
144 hours: config.updateCheckHours,
145 turns,
146 trafficOff: trafficIsOff,
147 newJobId,
148 kick: () => runner.kick(),
149 debug,
150 })
151 const health = createHealth(ports, {
152 registry,
153 store,
154 detector,
155 budget: DETECT_BUDGET,
156 config,
157 trafficOff: trafficIsOff,
158 debug,
159 timing,
160 })
161 return {
162 owner,
163 ports,
164 config,
165 store,
166 registry,
167 runner,
168 chrome,
169 catalog,
170 detector,
171 dev,
172 updater,
173 health,
174 timing,
175 jobFinished,
176 async showTab(tab) {
177 if (tab === 'discover') {
178 // Read at the first visit, then at most every few hours.
179 await catalog.load()
180 await catalog.show()
181 detector.start()
182 }
183 // What a session loads from folders changes with its commands and reloads: read on each visit.
184 if (tab === 'dev' || tab === 'health') await dev.refresh()
185 if (tab === 'health') await health.refresh()
186 },
187 turns,
188 newJobId,
189 dispose() {
190 runner.dispose()
191 detector.dispose()
192 updater.dispose()
193 void store.flush()
194 },
195 }
196}
197
198/** A short random id for a module instance. */
199export const newOwnerId = (random: () => number = Math.random): string =>
200 Math.floor(random() * 36 ** 8)
201 .toString(36)
202 .padStart(8, '0')
203