SLOPSHOPPER

hive

Tooling recommendations and session sharing for alignment researchers

newbandtoastpromptprocess
★ 26v0.6.6no licenseupdated 2026-10-09Crazytieguy/alignment-hive/plugins/hive
A shopper browsing a rack in a slop shop
README

hive

Core plugin for alignment-hive. Installed automatically by the install script.

What This Plugin Does

Tooling recommendations (/hive:align) — Walks through your project and recommends relevant plugins and dev tools. Tracks what you've already set up and what you've declined, so repeat runs only show new recommendations.

Session sharing — Opt-in system for sharing Claude Code session transcripts with AI safety research organizations. See alignment-hive.com/policy for what gets shared, who has access, and how to manage preferences. At the start of a session, rows above the prompt say what is about to upload, with Review sessions to preview it and Snooze 24h for an upload minutes away; once you're working, a row shows this session's state, with Keep private to leave it out.

Session retrieval — Search past Claude Code sessions from your local machine. An agent automatically searches session history when past context might be relevant, or you can search manually. A PostToolUse hook on EnterWorktree/ExitWorktree registers the session's new transcript dir, so moved sessions stay searchable.

CLI auto-updates — Keeps the hive CLI binary up to date automatically at session start.

Source 3 files
hooks/register.tsx 247 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { noticeRows } from './notice-band'
5import type { Notice, NoticeAction } from './notice-band'
6import type { HiveBusy, HiveNotices } from '../types'
7
8// `when`: which phase of the session shows the row (packages/hive-cli/src/commands/notices.ts).
9type HiveNotice = Notice & { when?: 'start' | 'working' }
10
11const PLUGIN = 'hive'
12const notices = atom({ plugin: 'hive', key: 'notices' } as const, null as HiveNotices | null)
13const dismissed = atom({ plugin: 'hive', key: 'dismissed' } as const, [] as string[])
14// The session the person has sent a message in; a /clear starts a new one, back at its start.
15const promptedSession = atom({ plugin: 'hive', key: 'promptedSession' } as const, null as string | null)
16// A row whose button's command is running: its buttons give way to the label until it settles.
17const busy = atom({ plugin: 'hive', key: 'busy' } as const, null as HiveBusy | null)
18// The review page's server is running: it serves until stopped, so Review sessions waits for it.
19const isReviewing = atom({ plugin: 'hive', key: 'isReviewing' } as const, false)
20
21// Each `hive notices` costs a few seconds of CPU and a backend call, so a turn refreshes only when
22// something can have changed: a new session id, a session the CLI has not discovered yet, or rows
23// older than this (the pending count and its countdown drift slowly).
24const REFRESH_MS = 15 * 60 * 1000
25
26// The directory the session started in, which hive.sh reads for the dev binary.
27let projectDir = ''
28let running: Promise<void> | null = null
29// A refresh asked for while one runs: the running one goes again, so its awaiters see fresh rows.
30let isRefreshDue = false
31// The session a stored reply already asked for: the reply hook asks once per session.
32let askedOnReply = ''
33
34/** Runs the CLI this plugin pins (scripts/hive.sh) in the session's working directory. */
35async function hive($: EngineInterface, args: string[]) {
36  return $.process.run(['bash', `${$.plugin.root}/scripts/hive.sh`, ...args], {
37    // NO_COLOR: the CLI's words reach a toast, not a terminal.
38    env: { CLAUDE_PLUGIN_ROOT: $.plugin.root, CLAUDE_PROJECT_DIR: projectDir, NO_COLOR: '1' },
39    timeoutMs: 60_000,
40  })
41}
42
43/** Fetches this session's rows once; drops the answer if the session changed (a /clear) meanwhile. */
44async function fetchNotices($: EngineInterface): Promise<void> {
45  const [sessionId, prompted] = await Promise.all([$.session.id(), read($, promptedSession)])
46  let found: { notices: unknown[]; isSessionKnown: boolean } = { notices: [], isSessionKnown: false }
47  try {
48    // Past the first message only this session's row shows: the CLI skips the project-wide scan,
49    // most of a full call's time.
50    const { exitCode, stdout } = await hive($, ['notices', sessionId, ...(prompted === sessionId ? ['--session-only'] : [])])
51    if (exitCode === 0 && stdout.trim()) found = JSON.parse(stdout) as typeof found
52  } catch {
53    // A check that fails shows nothing rather than a wrong row, and is asked again next turn.
54  }
55  const fetchedAt = await $.clock.now()
56  if ((await $.session.id()) !== sessionId) return
57  await update($, notices, (): HiveNotices => ({ sessionId, rows: found.notices, isSessionKnown: found.isSessionKnown, fetchedAt }))
58}
59
60/** Refreshes the rows; resolves once they reflect everything asked before it returned. */
61function refresh($: EngineInterface): Promise<void> {
62  if (running) {
63    isRefreshDue = true
64    return running
65  }
66  running = loop($).finally(() => {
67    running = null
68  })
69  return running
70}
71
72async function loop($: EngineInterface): Promise<void> {
73  do {
74    isRefreshDue = false
75    await fetchNotices($)
76  } while (isRefreshDue)
77}
78
79/**
80 * While the review page's server runs: it serves until stopped (in /tasks), so its row shows it
81 * open and the button comes back once the process is gone. pgrep reads, nothing more.
82 */
83async function watchReview($: EngineInterface): Promise<void> {
84  await update($, isReviewing, () => true)
85  try {
86    await $.clock.sleep(5_000)
87    for (;;) {
88      const { exitCode } = await $.process.run(['pgrep', '-f', '/hive upload review$'])
89      if (exitCode !== 0) return
90      await $.clock.sleep(10_000)
91    }
92  } catch {
93    // Not knowing whether it runs, the button comes back.
94  } finally {
95    await update($, isReviewing, () => false)
96  }
97}
98
99/** Whether the rows can say more about this session: none yet, another session's, or undiscovered. */
100function isUnsettled(current: HiveNotices | null, sessionId: string): boolean {
101  return current?.sessionId !== sessionId || !current.isSessionKnown
102}
103
104/** Whether a turn's end should refresh: see REFRESH_MS. */
105async function isRefreshNeeded($: EngineInterface): Promise<boolean> {
106  const [current, sessionId, now] = await Promise.all([read($, notices), $.session.id(), $.clock.now()])
107  return isUnsettled(current, sessionId) || now - (current?.fetchedAt ?? 0) > REFRESH_MS
108}
109
110/**
111 * Runs a CLI command for a row's button, showing `label` in the row's place until the rows are
112 * fresh again; the CLI's words go to a toast only when it fails (the row shows success).
113 */
114async function runForRow($: EngineInterface, row: string, label: string, args: string[]): Promise<void> {
115  await update($, busy, (): HiveBusy => ({ row, label }))
116  try {
117    const { exitCode, stdout, stderr } = await hive($, args)
118    if (exitCode !== 0) $.ui.toast((stderr || stdout).trim() || `hive ${args.join(' ')} failed`)
119    await refresh($)
120  } finally {
121    await update($, busy, () => null)
122  }
123}
124
125/** Work nobody awaits: an unload (a reload, the session's end) aborts it, which is no failure. */
126function inBackground(work: Promise<unknown>): void {
127  work.catch(() => undefined)
128}
129
130/** A shell word: the path in single quotes. */
131function quoted(text: string): string {
132  return `'${text.replaceAll("'", `'\\''`)}'`
133}
134
135export const register: Register = on => {
136  on('session.start', async ($, e, next) => {
137    const started = await next(e)
138    projectDir = e.cwd
139    inBackground(refresh($))
140    return started
141  })
142
143  on('prompt.submit', async ($, e, next) => {
144    const [sessionId, prompted] = await Promise.all([$.session.id(), read($, promptedSession)])
145    if (prompted !== sessionId) await update($, promptedSession, () => sessionId)
146    return next(e)
147  })
148
149  // The CLI discovers a session once it holds an assistant reply: the first reply row stored in
150  // the main conversation of a session it does not know yet is the moment to ask again, once per
151  // session, so this session's row comes before the turn ends. The turn's end asks again if
152  // that answer came too soon.
153  on('session.append', { door: 'response' }, async ($, e, next) => {
154    const stored = await next(e)
155    if (e.agentId === undefined) {
156      const [current, sessionId] = await Promise.all([read($, notices), $.session.id()])
157      if (askedOnReply !== sessionId && isUnsettled(current, sessionId)) {
158        askedOnReply = sessionId
159        inBackground(refresh($))
160      }
161    }
162    return stored
163  })
164
165  // A new session is not discovered until its first reply, and a /clear changes the session id
166  // without a session.start.
167  on('turn.complete', async ($, e, next) => {
168    if (await isRefreshNeeded($)) inBackground(refresh($))
169    return next(e)
170  })
171
172  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
173    const [below, current, hidden, prompted, busyRow, reviewing, sessionId] = await Promise.all([
174      next(e),
175      read($, notices),
176      read($, dismissed),
177      read($, promptedSession),
178      read($, busy),
179      read($, isReviewing),
180      $.session.id(),
181    ])
182    if (e.props.hasSurvey || current?.sessionId !== sessionId) return below
183
184    const phase = prompted === sessionId ? 'working' : 'start'
185    const rows = (current.rows as HiveNotice[])
186      .filter(notice => !hidden.includes(notice.id) && (notice.when === undefined || notice.when === phase))
187      .map(notice => {
188        if (busyRow?.row === notice.id) return { ...notice, busy: busyRow.label }
189        if (reviewing && notice.actions?.some(action => action.id === 'review')) {
190          return { ...notice, busy: 'review page open, stop it in /tasks' }
191        }
192        return notice
193      })
194    if (rows.length === 0) return below
195
196    const onAction = async (notice: Notice, action: NoticeAction) => {
197      if (action.kind === 'copy' && action.command) {
198        // The install script and `hive login` ask questions, so they run in a terminal of the
199        // person's own.
200        const copied = await $.ui.copy({ text: action.command, surface: e.surface })
201        $.ui.toast(copied.isCopied ? 'Copied: paste it into a terminal' : `Run in a terminal: ${action.command}`)
202        return
203      }
204      if (action.kind === 'command' && action.command) {
205        await update($, dismissed, list => [...list, notice.id])
206        await $.command.run({ command: action.command.slice(1) }).catch(() => $.ui.toast(`Could not run ${action.command}`))
207        return
208      }
209      if (action.id === 'keep-private') {
210        await runForRow($, notice.id, 'keeping private…', ['upload', 'exclude', sessionId])
211        return
212      }
213      if (action.id === 'snooze') {
214        await runForRow($, notice.id, 'snoozing…', ['upload', 'snooze', '24h'])
215        return
216      }
217      if (action.id === 'review') {
218        // A background shell task of the session (listed in /tasks, stopped there): a local
219        // server that opens the review page in the browser.
220        const root = $.plugin.root
221        const started = await $.tool
222          .call({
223            tool: 'Bash',
224            command: `CLAUDE_PLUGIN_ROOT=${quoted(root)} CLAUDE_PROJECT_DIR=${quoted(projectDir)} bash ${quoted(`${root}/scripts/hive.sh`)} upload review`,
225            description: 'Open the hive session review page',
226            run_in_background: true,
227          })
228          .catch(() => null)
229        // A denied or failed start leaves the button as it was.
230        if (started?.result) inBackground(watchReview($))
231        else $.ui.toast('Could not start the review page')
232      }
233    }
234    const onDismiss = (notice: Notice) => update($, dismissed, list => [...list, notice.id])
235
236    const ui = $.ui.resolve(e)
237    const drawn = noticeRows(ui, PLUGIN, rows, { onAction, onDismiss })
238
239    return (
240      <ui.Box flexDirection="column">
241        {drawn}
242        {below}
243      </ui.Box>
244    )
245  })
246}
247
hooks/notice-band.tsx 92 lines
1import type { EngineInterface, RenderElement } from 'claude-code'
2
3// One plugin's rows in the band above the prompt, drawn without `$`: each
4// plugin's own `ui.render` hook reads its state and stacks this over what the
5// plugins beneath drew, so third-party plugins on the band keep working.
6//
7// A plugin cannot import another's files, so each plugin that shows notices
8// carries this file; plugins/notice-band.test.ts keeps the copies identical.
9// The `notices` its check prints are this file's `Notice` as JSON.
10
11export type Severity = 'urgent' | 'problem' | 'action' | 'info'
12
13export type NoticeAction = {
14  id: string
15  label: string
16  // command: a slash command; copy: `command` goes on the clipboard, for a
17  // terminal command that is interactive; plugin: the plugin's own handler decides.
18  kind: 'command' | 'copy' | 'plugin'
19  command?: string
20}
21
22export type Notice = {
23  id: string
24  severity: Severity
25  // The whole message: rows wrap rather than hide what the person needs to act.
26  text: string
27  actions?: NoticeAction[]
28  // Set on "updated" notices: a dismissal holds until the plugin's next
29  // version. Without it, a dismissal holds for this session only.
30  version?: string
31  // What a pressed button is doing ("restarting…"): drawn after the text in
32  // place of the buttons, so the row never looks inert. Set by the plugin.
33  busy?: string
34}
35
36export type NoticeHandlers = {
37  onAction: (notice: Notice, action: NoticeAction) => unknown
38  onDismiss: (notice: Notice) => unknown
39}
40
41const MARK: Record<Severity, { rank: number; glyph: string; color: string }> = {
42  urgent: { rank: 0, glyph: '✖', color: 'error' },
43  problem: { rank: 1, glyph: '!', color: 'warning' },
44  action: { rank: 2, glyph: '›', color: 'suggestion' },
45  info: { rank: 3, glyph: '↑', color: 'ide' },
46}
47
48export function noticeRows(
49  ui: ReturnType<EngineInterface['ui']['resolve']>,
50  plugin: string,
51  notices: Notice[],
52  { onAction, onDismiss }: NoticeHandlers,
53): RenderElement[] {
54  const { Box, Button, Text } = ui
55
56  return [...notices]
57    .sort((a, b) => MARK[a.severity].rank - MARK[b.severity].rank)
58    .map(notice => {
59      const mark = MARK[notice.severity]
60      const key = `${plugin}:${notice.id}`
61
62      const buttons = (notice.busy ? [] : (notice.actions ?? [])).map((action, index) => (
63        <Button
64          key={`${key}:${action.id}`}
65          label={action.label}
66          variant={index === 0 ? 'primary' : 'secondary'}
67          onPress={() => onAction(notice, action)}
68        />
69      ))
70      // An info row is a state, not a request: the band's own collapse hides it.
71      if (notice.severity !== 'info' && !notice.busy) {
72        buttons.push(<Button key={`${key}:dismiss`} label="Dismiss" dimColor onPress={() => onDismiss(notice)} />)
73      }
74
75      // The buttons wrap under the text where the row does not fit.
76      return (
77        <Box key={key} flexDirection="row" flexWrap="wrap" justifyContent="space-between" columnGap={2}>
78          <Text>
79            <Text color={mark.color} bold>
80              {mark.glyph}{' '}
81            </Text>
82            <Text bold>{plugin}</Text> {notice.busy ? `${notice.text} · ${notice.busy}` : notice.text}
83          </Text>
84          {/* Grows to fill its line, so the buttons stay right-aligned when they wrap. */}
85          <Box flexDirection="row" flexGrow={1} justifyContent="flex-end" columnGap={1}>
86            {buttons}
87          </Box>
88        </Box>
89      )
90    })
91}
92
types/index.d.ts 20 lines
1// `hive notices <session id>` for one session: its rows are hooks/notice-band.tsx's Notice as
2// parsed JSON; `isSessionKnown` false while the CLI has not discovered the session yet;
3// `fetchedAt` the clock's time of the answer.
4export type HiveNotices = { sessionId: string; rows: unknown[]; isSessionKnown: boolean; fetchedAt: number }
5
6// A row whose button's command is running, and what the row says meanwhile.
7export type HiveBusy = { row: string; label: string }
8
9declare module 'claude-code' {
10  interface PluginState {
11    hive: {
12      notices: HiveNotices | null
13      dismissed: string[]
14      promptedSession: string | null
15      busy: HiveBusy | null
16      isReviewing: boolean
17    }
18  }
19}
20