Tooling recommendations and session sharing for alignment researchers

Core plugin for alignment-hive. Installed automatically by the install script.
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.
hooks/register.tsx 247 lines1import { 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}
247hooks/notice-band.tsx 92 lines1import 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}
92types/index.d.ts 20 lines1// `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