SLOPSHOPPER

modmgr

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

newpanebandcommandstatusprocess
★ 1v0.3.0MITupdated 2026-10-09ayagmar/claude-modmgr/plugin
A shopper browsing a rack in a slop shop
README

modmgr

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.

  • Installed: every mod with its state, version and notable capabilities; changes are staged, applied together with one plugin reload, and the last batch can be undone.
  • Discover: the mods in every marketplace you have added, searchable, each installed through a review that says what will run and where.
  • Dev: the mods you are writing (--plugin-dir, CLAUDE_CODE_PLUGIN_DIRS, your skills folder, folder marketplaces): validate, test, reload, share.
  • Health: what needs you, worst first, each with a fix one key away.

/mods list, info, doctor, export and friends answer as text for scripts and -p runs.

What it runs, reads and fetches

  • Runs the 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).
  • Fetches, without credentials, only from 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.
  • Reads plugin manifests in your marketplaces' folders and where mods under development live, and, in a session started with --debug, its own debug log for failed hooks. It never reads your settings or the CLI's own files.
  • Stores preferences, capability analyses and the last 50 finished jobs (no output) in its plugin store under your Claude Code config directory. No conversation text, secrets or environment values.
  • Sends nothing else anywhere, and has no telemetry.

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.

Source 61 files
hooks/register.tsx 386 lines
1// 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}
386
hooks/domain/config.ts 42 lines
1// 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}
42
hooks/domain/dev.ts 433 lines
1// 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}
433
hooks/domain/discover.ts 229 lines
1// 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}
229
hooks/domain/health.ts 369 lines
1// 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}
369
hooks/domain/state.ts 83 lines
1// 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}
83
hooks/domain/view.ts 789 lines
1// 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}
789
hooks/ports.ts 147 lines
1// 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}
147
hooks/services/actions.ts 883 lines
1// 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}
883
hooks/services/commands.ts 318 lines
1// `/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}
318
hooks/services/lifecycle.ts 126 lines
1// 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}
126
hooks/services/runtime.ts 203 lines
1// 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