Shares notices with every session in the same repository, or on the machine

マシン上で同時に動く Claude Code のセッションへ、リポジトリ単位でお知らせを届ける mod です。 「CI を止めているので Codex のレビューだけ見ればよい」のような状況の変化を、セッションごとに打ち直さずに済みます。
notice 2h: CI is paused, check the Codex review only
notice (all) 1d: Rate limits are tight this week
/notice <本文> 同じリポジトリのセッション全体に出す
/notice --all <本文> リポジトリを問わず、マシン上の全セッションに出す
/notice clear このセッションの帯に出ているお知らせをすべて消す
投稿したセッションが終わっても、お知らせは残ります。 期限はなく、消えるのは /notice clear を実行したときだけです。 帯には投稿からの経過時間を出すので、古いお知らせは見ればわかります。
mod が動く Claude Code のバージョンは usage-meter と同じです。
Claude Code のプロンプトで、次の 3 つを順に実行します。
/plugin marketplace add HolyGrail/claude-mods
/plugin install notice-board@claude-mods
/reload-plugins
更新の手順と注意点は、リポジトリの README にまとめてあります。
clone したリポジトリのルートで、次のように起動します。
claude --plugin-dir ./plugins/notice-board
手元のコードを常に読み込むなら、~/.claude/settings.json の env に CLAUDE_CODE_PLUGIN_DIRS としてこのディレクトリの絶対パスを書きます。
各セッションは 60 秒ごとにお知らせを読み直します。 別のセッションで投稿したお知らせは、遅くとも 1 分後には帯に出ます。
モデルには、新しいお知らせを見つけたときに 1 回だけ、会話へ 1 行足して伝えます。 system prompt には書き込みません。 書き込むと、お知らせが変わるたびに prompt cache が無効になるからです。 伝えたお知らせが /notice clear で消えたときや、セッションが別のリポジトリへ移って対象から外れたときも、取り下げを 1 回だけ伝えます。 伝えなければ、モデルはもう当てはまらないお知らせに従い続けます。 ただし、同じ本文のお知らせがほかに出ているあいだは、取り下げを伝えません。
投稿したセッションのモデルにも、同じ経路で伝えます。 対話セッションでは、コマンドの出力はモデルが読む会話に入らないからです。
どのお知らせを伝えたかは、会話そのものから読み直します。 mod はお知らせ 1 件ごと、取り下げ 1 件ごとに会話へ 1 行足し、その行を会話の頭から順にたどって、伝えたものと取り下げたものを突き合わせます。 行の末尾には (notice-board ref …) を付けます。これはマシンごとの秘密の値で本文から作った印で、印の合わない行(同じ文面を手で打ったプロンプトなど)は mod の行として扱いません。 印のないバージョン 0.1 の行は、そのセッションで 0.1 が伝えたと記録しているお知らせに限って読みます。 読み直すのは 60 秒ごとと、プロンプトや /notice の実行時です。 そのため、会話が入れ替わったり縮んだりしても、いまの会話に合わせて伝え直します。
/clear、コンパクションのあとは、会話に行が残っていないので、いま出ているものを改めて伝えます/resume や --resume で読み込んだ会話には、その会話にない分だけを伝えます。読み込んだ会話で伝えたお知らせがその後消えていれば、取り下げを伝えます/rewind や復元ピッカーで巻き戻して行が消えたときは、次のプロンプトの前に改めて伝えます/branch で分けた会話は元の会話の行を引き継ぐので、重ねては伝えません/resume は新しい会話を mod の受け取るイベントのあとに読み込むので、その間の読み直しが元の会話へ伝えることがあります。 新しい会話には、次の読み直しで伝えます。
origin の URL を host/owner/name の形にそろえて比べます。 git@github.com:owner/app と https://github.com/owner/app.git は同じリポジトリです。 ホスト名の大文字小文字は区別しませんが、パスの大文字小文字は区別します。パスを区別する Git サーバーもあるからです。 ポート番号も残すので、同じホストの別ポートにあるリポジトリは別物として扱います。 同じリポジトリの worktree も別のクローンも、同じお知らせを受け取ります。 origin がないリポジトリと、origin がローカルのパスや file:// のリポジトリは、main working tree のパスで比べます。 ローカルの origin は ../upstream.git のように相対パスで書けるので、綴りが同じでも別のリポジトリを指すことがあるからです。
$.store は mod ごとに分かれていて、ほかの mod からはお知らせのキーを書けません。 ほかの mod は、/notice を $.command.run で呼んで投稿します。
await $.command.run({ command: 'notice', args: 'main advanced (#12). Rebase before the next push.' })
pr-relay は、監視中の PR のマージを検知したときに、この形で「main が進んだ」と出します。 文面には、次の push の前に rebase するよう書くことを勧めます。 作業の途中で rebase すると、コンフリクトの解消が実装の変更と混ざるからです。 $.command.run はセッションが待機中になってから実行されるので、投稿は数分遅れることがあります。
$.store には atomic な更新がないため、お知らせごとに notice:<id> のキーを分けています。 投稿がほかのお知らせを上書きすることはありません。 ただし、/notice clear の実行中に投稿されたお知らせは、消えずに残ることがあります。
伝えたかどうかは、お知らせの範囲(リポジトリかマシン全体か)と本文で見分けます。 範囲と本文が同じお知らせが二つ出ていても、モデルには 1 回だけ伝えます。 $.session.messages() が返すのは新しい 4096 件までなので、それより前に伝えた行は会話にないものとして、もう一度伝えます。 会話が 4096 件を超えたり compaction で要約されたりして伝えた行が見えなくなっても取り下げを伝えられるよう、会話ごとに伝えたお知らせを $.store の told:<セッション ID> にも残します。 この記録は取り下げにだけ使い、まだ出ているお知らせの行が見えなくなったときはもう一度伝えます。 この記録は 8 日間読み込まれなかった会話の分から消します。
モデルへの伝達が拒否されたとき(プラグインが会話を変えられない実行や、上位のプラグインによる拒否)は、同じ会話のうちは再試行しません。 どちらも次の試行で結果が変わらないからです。帯には出たままになり、伝えられなかった行は debug ログに残ります。 会話が入れ替わったときや巻き戻したとき、および mod の再読み込みのあとは、一度だけ試し直します。
cd plugins/notice-board
claude plugin test
テストキットでは、mod の $.session.append がテストのフックに届かず、失敗します。 append が失敗すると、mod はモデルに伝えられなかった行を debug ログに残すので、テストはその行でモデルに渡す内容を確かめています。 テストの $.session.messages() は、その行を会話に足した形で返します。/clear、/resume、/rewind は、テストが会話を入れ替えて再現します。
hooks/register.js 481 lines1// Shares notices across the sessions on this machine: /notice posts one to every session in the
2// same repository (or, with --all, to every session), each shows it above the prompt and passes
3// it on to its model once, and /notice clear takes it down everywhere.
4
5// The notices this session shows, newest first
6let notices = []
7let sessionId = null
8// The timer that picks up other sessions' notices, kept so a later session.start can stop it
9let ticker = null
10// Loads run one at a time, so a tick and a command can't pass the same notice on twice
11let queue = Promise.resolve()
12// The rows the host refused to add, by key, so a refusal is not retried every tick. Forgotten
13// when the conversation is replaced or rewound, which may want them again.
14const refused = new Set()
15// The conversation as the last load read it while refusals were held, one fingerprint per message
16let seen = []
17// Bumped by every session.start and conversation switch, so a load begun before one doesn't record
18// its refusals against the conversation that follows
19let generation = 0
20// The texts version 0.1 told this session of, from its $.state record, which vouch for its
21// unsigned rows. A promise, set along with sessionId and with no wait between, so a load that
22// takes the id takes the record of the same conversation, however long it takes to read
23let legacy = Promise.resolve(new Set())
24// Counts this module's posts, so two in the same millisecond get keys of their own
25let posts = 0
26
27// $.store has no atomic update, so each notice has a key of its own: posting never overwrites
28// another session's notice, and clearing deletes keys instead of rewriting a shared list
29const KEY_PREFIX = 'notice:'
30// What the model was told is read back from the conversation itself, so a reload, /clear,
31// compaction, /resume, /rewind and /branch each leave it right. Each notice or withdrawal is a row
32// of its own, whose whole text is one of these, so a notice's body may hold anything, new lines
33// that look like another row included. The ref that ends a row is a hash of its body under a
34// secret kept in $.store, so a prompt that only reads like a row is not taken for one. Each
35// secret has a key of its own, so one that signed a row is never overwritten by another.
36const TOLD = /^Notice to every Claude Code session (on this machine|in this repository), posted \d+[mhd] ago with \/notice: ([\s\S]*)\n\(notice-board ref ([0-9a-f]{8})\)$/
37const WITHDRAWN = /^This notice no longer applies: ([\s\S]*)\n\(notice-board ref ([0-9a-f]{8})\)$/
38const SECRET_PREFIX = 'secret:'
39// Version 0.1 joined its lines into one row, unsigned
40const TOLD_V1 = /^Notice to every Claude Code session (on this machine|in this repository), posted \d+[mhd] ago with \/notice: ([\s\S]*)$/
41const WITHDRAWN_V1 = /^This notice no longer applies: ([\s\S]*)$/
42const LINE_START_V1 = /\n(?=Notice to every Claude Code session |This notice no longer applies: )/
43const KNOWN_V1 = { plugin: 'notice-board', key: 'known' }
44// $.session.messages() returns at most this many, the newest
45const WINDOW = 4096
46// What each conversation was told, under the session id, for the rows a full window no longer
47// returns: { at, told: [{ key, text }] }
48const RECORD_PREFIX = 'told:'
49// A record not written for this long is deleted
50const DAY_MS = 24 * 60 * 60 * 1000
51const STALE_MS = 8 * DAY_MS
52// How often to pick up notices other sessions posted or cleared
53const TICK_MS = 60_000
54// The band shows this many notices, newest first, and counts the rest
55const MAX_SHOWN = 3
56
57// The session.end reasons after which this module stops; /clear, /resume and logout leave it running
58const FINAL_REASONS = ['prompt_input_exit', 'other']
59
60const USAGE = 'Usage: /notice <text> | /notice --all <text> | /notice clear'
61
62export function register(on) {
63 // Fires again on an enable or a worker respawn, which may keep this module's variables
64 on('session.start', async ($, e, next) => {
65 ticker?.cancel()
66 // The queue stays: a load the previous start began may still be running
67 notices = []
68 forgetRefusals()
69 sessionId = await $.session.id()
70 legacy = legacyOf($)
71 await prune($)
72 await $.command.register({
73 name: 'notice',
74 description: 'Post a notice to every session in this repository (--all: on this machine), or clear them',
75 argumentHint: '[--all] <text> | clear',
76 })
77 await refresh($)
78 ticker = $.clock.every(TICK_MS, () => refresh($))
79 return next(e)
80 })
81
82 // /clear, /resume and /branch (fork) switch to another session id, which later posts carry. The
83 // conversation they leave is read at the next load: /resume installs its own only after this hook,
84 // so a tick in between may tell the outgoing one, and the load after it tells the new one
85 on('classic.SessionStart', { source: ['clear', 'compact', 'resume', 'fork'] }, async ($, e, next) => {
86 const outgoing = sessionId
87 // The host has moved to the new id already; it is taken up only once the switch is done, with
88 // no wait in between, so a tick during the switch still reads the outgoing conversation and
89 // keeps its record and refusals to it, and one after it reads the new one under the new id
90 const incoming = await $.session.id()
91 forgetRefusals()
92 const result = await next(e)
93 sessionId = incoming
94 legacy = legacyOf($)
95 forgetRefusals()
96 // A fork copies the conversation, and with it what was told before its window. In the queue,
97 // so no load of either conversation writes its record meanwhile; one under the new id may have
98 // written a record already, from the rows its window shows, so the two are merged. Not awaited:
99 // a load ahead of it may be waiting on the host, which may be waiting on this hook
100 if (e.source === 'fork' && outgoing !== null && outgoing !== sessionId) {
101 const target = sessionId
102 void serially(async () => {
103 const record = await $.store.get(RECORD_PREFIX + outgoing)
104 if (!isRecord(record)) return
105 const current = await $.store.get(RECORD_PREFIX + target)
106 const told = isRecord(current) ? current.told : []
107 const merged = [...told, ...record.told.filter((r) => !told.some((t) => t.key === r.key))]
108 if (merged.length === told.length) return
109 await $.store.set(RECORD_PREFIX + target, { at: isRecord(current) ? current.at : record.at, told: merged })
110 }).catch(() => {})
111 }
112 return result
113 })
114
115 // Before the model reads a prompt, the conversation it reads is brought up to date: /rewind
116 // raises no event of its own, and the lines it took back are retold here
117 on('prompt.submit', async ($, e, next) => {
118 // A load that fails must not hold the prompt back; the next tick tries again
119 await refresh($).catch(() => {})
120 return next(e)
121 })
122
123 on('session.end', async ($, e, next) => {
124 if (FINAL_REASONS.includes(e.reason)) ticker?.cancel()
125 return next(e)
126 })
127
128 // Answers /notice, from the person or from another plugin's $.command.run (pr-relay posts
129 // "main advanced" this way, since a plugin's $.store is its own)
130 on('command.run', { command: 'notice' }, async ($, e) => {
131 const args = e.args.trim()
132 if (args === 'clear') return { text: await clear($) }
133 const all = /^--all(\s|$)/.test(args)
134 const text = (all ? args.slice('--all'.length) : args).trim()
135 if (!text) return { text: USAGE }
136 return { text: await post($, text, all) }
137 })
138
139 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
140 if (notices.length === 0) return next(e)
141 const { Box, Text } = $.ui.resolve(e)
142 const now = await $.clock.now()
143 const rows = notices.slice(0, MAX_SHOWN).map((notice) =>
144 Box({
145 key: 'notice-' + notice.id,
146 flexDirection: 'row',
147 columnGap: 1,
148 children: [
149 Text({ dimColor: true, children: [label(notice, now)] }),
150 Text({ wrap: 'truncate-end', children: [notice.text] }),
151 ],
152 }),
153 )
154 const hidden = notices.length - MAX_SHOWN
155 if (hidden > 0) rows.push(Text({ key: 'notice-more', dimColor: true, children: ['+' + hidden + ' more'] }))
156 const board = Box({ flexDirection: 'column', children: rows })
157 // Keep what the mods after this one draw in the band
158 const rest = await next(e)
159 if (!rest) return board
160 return Box({ flexDirection: 'column', children: [board, rest] })
161 })
162}
163
164async function post($, text, all) {
165 const repoKey = repoKeyOf(await $.session.repo())
166 if (!all && repoKey === null) return 'Not in a git repository. Use /notice --all <text> to post to every session.'
167 const postedAt = await $.clock.now()
168 posts += 1
169 await $.store.set(KEY_PREFIX + postedAt + '-' + sessionId + '-' + posts, { text, repo: all ? null : repoKey, postedAt })
170 await refresh($)
171 return all ? 'Posted to every session on this machine.' : 'Posted to every session in this repository.'
172}
173
174// Takes down every notice this session shows; each other session tells its model at its next tick
175async function clear($) {
176 await refresh($)
177 const cleared = notices
178 await Promise.all(cleared.map((notice) => $.store.delete(KEY_PREFIX + notice.id)))
179 await refresh($)
180 if (cleared.length === 0) return 'No notices to clear.'
181 return 'Cleared ' + cleared.length + (cleared.length === 1 ? ' notice.' : ' notices.')
182}
183
184// Loads the notices after every earlier load has settled, whether it succeeded or not, and redraws
185function refresh($) {
186 return serially(() => load($)).then(() => $.ui.invalidate('ui.render'))
187}
188
189// Runs task once every earlier one has settled
190function serially(task) {
191 const run = queue.then(task, task)
192 queue = run.catch(() => {})
193 return run
194}
195
196// Reads the notices meant for this session, and tells the model about the ones its conversation
197// does not hold and the ones it holds that have since been cleared
198async function load($) {
199 // Before any wait, so a restart or a switch that lands during one is seen
200 const started = generation
201 const id = sessionId
202 const known = legacy
203 const repoKey = repoKeyOf(await $.session.repo())
204 const keys = (await $.store.keys()).filter((key) => key.startsWith(KEY_PREFIX))
205 const values = await Promise.all(keys.map((key) => $.store.get(key)))
206 const shown = []
207 keys.forEach((key, i) => {
208 const notice = values[i]
209 if (!isNotice(notice)) return
210 const id = key.slice(KEY_PREFIX.length)
211 if (notice.repo === null || (repoKey !== null && notice.repo === repoKey)) shown.push({ id, ...notice })
212 })
213 // At an equal time the later id comes first, so every session lists them alike
214 shown.sort((a, b) => b.postedAt - a.postedAt || (a.id < b.id ? 1 : -1))
215 notices = shown
216
217 const messages = await $.session.messages()
218 if (refused.size > 0) {
219 const now = messages.map(fingerprint)
220 if (!continues(seen, now)) refused.clear()
221 seen = now
222 }
223 const secrets = await secretsOf($)
224 const { told, withdrawals } = await toldIn(messages, secrets, await known)
225 // Rows this conversation was told may be gone from what is read, dropped by a full window or
226 // folded into a compaction's summary: the record keeps those, less what the conversation shows
227 // was withdrawn since. They only count for withdrawing; a notice still shown whose row is gone is
228 // told again
229 const record = await $.store.get(RECORD_PREFIX + id)
230 const recorded = isRecord(record) ? record.told : []
231 const earlier = recorded.filter((r) => !told.has(r.key) && !withdrawals.has(r.text))
232 const shownTexts = new Set(shown.map((notice) => notice.text))
233 // This session's own posts too: a command's output is not part of what the model reads. Oldest
234 // first, and one row for notices that read the same
235 const fresh = []
236 for (const notice of [...shown].reverse()) {
237 const key = toldKey(notice)
238 if (!told.has(key) && !refused.has(key) && !fresh.some((f) => toldKey(f) === key)) fresh.push(notice)
239 }
240 // Cleared, or meant for a repository this session has left; one whose text another notice shown
241 // here still carries still applies
242 const toldTexts = new Set([...told.values(), ...earlier.map((r) => r.text)])
243 const withdrawn = [...toldTexts].filter((text) => !shownTexts.has(text) && !refused.has('withdrawn\n' + text))
244
245 // What the conversation holds once this load's rows are in
246 const holds = new Map([...earlier.map((r) => [r.key, r.text]), ...told])
247 if (fresh.length > 0 || withdrawn.length > 0) {
248 const now = await $.clock.now()
249 const rows = [
250 ...fresh.map((notice) => ({
251 key: toldKey(notice),
252 text: notice.text,
253 line:
254 'Notice to every Claude Code session ' +
255 (notice.repo === null ? 'on this machine' : 'in this repository') +
256 ', posted ' + ago(now - notice.postedAt) + ' ago with /notice: ' + notice.text,
257 })),
258 ...withdrawn.map((text) => ({ key: 'withdrawn\n' + text, text, line: 'This notice no longer applies: ' + text })),
259 ]
260 for (const row of rows) {
261 const line = row.line + '\n(notice-board ref ' + (await ref(secrets[0], row.text)) + ')'
262 // A restart or a switch since this load read the conversation: its rows may not fit the one
263 // installed now, which the next load reads afresh. Checked after the last wait before the
264 // append.
265 if (generation !== started) return
266 const result = await $.session
267 .append({ message: { type: 'user', content: [{ type: 'text', text: line }] } })
268 .catch((error) => ({ deny: error instanceof Error ? error.message : String(error) }))
269 if (result.deny === undefined) {
270 if (row.key.startsWith('withdrawn\n')) {
271 for (const [key, text] of holds) if (text === row.text) holds.delete(key)
272 } else holds.set(row.key, row.text)
273 continue
274 }
275 // A run no plugin may shape refuses the row; the band still shows the notices. A refusal met
276 // before a restart or a switch is not held against what follows it.
277 if (generation === started) {
278 if (refused.size === 0) seen = messages.map(fingerprint)
279 refused.add(row.key)
280 }
281 $.ui.log('notice-board could not tell the model: ' + result.deny + '\n' + line, { to: 'debug' })
282 }
283 }
284 // Only a load the switch didn't overtake writes, so the record stays the conversation's own
285 if (generation !== started) return
286 const holding = [...holds].map(([key, text]) => ({ key, text }))
287 const now = await $.clock.now()
288 // Unchanged, it is still written now and then, so prune keeps a conversation still in use
289 if (JSON.stringify(holding) === JSON.stringify(recorded) && (holding.length === 0 || now - record.at < DAY_MS)) return
290 if (holding.length === 0) await $.store.delete(RECORD_PREFIX + id)
291 else await $.store.set(RECORD_PREFIX + id, { at: now, told: holding })
292}
293
294function forgetRefusals() {
295 generation += 1
296 refused.clear()
297 seen = []
298}
299
300// The notices the conversation tells the model of, by key, with their text: each row this module
301// added in order, a withdrawal taking back every notice of its text; and the texts withdrawn
302async function toldIn(messages, secrets, legacy) {
303 const signs = async (text, r) => {
304 for (const secret of secrets) if (r === (await ref(secret, text))) return true
305 return false
306 }
307 const told = new Map()
308 const withdrawals = new Set()
309 for (const message of messages) {
310 if (message.role !== 'user') continue
311 const added = TOLD.exec(message.text)
312 if (added && (await signs(added[2], added[3]))) {
313 told.set((added[1] === 'on this machine' ? 'all' : 'repo') + '\n' + added[2], added[2])
314 withdrawals.delete(added[2])
315 continue
316 }
317 const withdrawn = WITHDRAWN.exec(message.text)
318 if (withdrawn && (await signs(withdrawn[1], withdrawn[2]))) {
319 for (const [key, value] of told) if (value === withdrawn[1]) told.delete(key)
320 withdrawals.add(withdrawn[1])
321 continue
322 }
323 // A row of version 0.1 counts where that version's record says it told the text
324 if (legacy.size === 0) continue
325 const parts = partsV1(message.text, legacy)
326 if (parts === null) continue
327 for (const part of parts) {
328 const added = TOLD_V1.exec(part)
329 if (added && legacy.has(added[2])) {
330 told.set((added[1] === 'on this machine' ? 'all' : 'repo') + '\n' + added[2], added[2])
331 withdrawals.delete(added[2])
332 }
333 const withdrawnV1 = WITHDRAWN_V1.exec(part)
334 if (withdrawnV1 && legacy.has(withdrawnV1[1])) {
335 for (const [key, value] of told) if (value === withdrawnV1[1]) told.delete(key)
336 withdrawals.add(withdrawnV1[1])
337 }
338 }
339 }
340 return { told, withdrawals }
341}
342
343// Splits a row of version 0.1 into its lines. A body may itself hold a new line that starts like
344// another line, so a body the record lists is taken whole first, the longest such; any other runs
345// to the next line start.
346function partsV1(text, legacy) {
347 const parts = []
348 let rest = text
349 while (rest !== '') {
350 const head = /^(?:Notice to every Claude Code session (?:on this machine|in this repository), posted \d+[mhd] ago with \/notice: |This notice no longer applies: )/.exec(rest)
351 if (!head) return null
352 const body = rest.slice(head[0].length)
353 const known = [...legacy]
354 .filter((t) => body.startsWith(t) && (body.length === t.length || body.slice(t.length).search(LINE_START_V1) === 0))
355 .sort((a, b) => b.length - a.length)[0]
356 const end = known !== undefined ? known.length : body.search(LINE_START_V1)
357 const length = end === -1 ? body.length : end
358 parts.push(head[0] + body.slice(0, length))
359 rest = body.slice(length).replace(/^\n/, '')
360 }
361 return parts
362}
363
364// The texts version 0.1's record holds, if this session ran it
365async function legacyOf($) {
366 try {
367 const known = (await $.state.get(KNOWN_V1)).value
368 return new Set(Array.isArray(known) ? known.map((k) => k?.text).filter((t) => typeof t === 'string') : [])
369 } catch {
370 return new Set()
371 }
372}
373
374// The secrets rows on this machine are signed with, the one new rows are signed with first. The
375// first session that needs one makes it; sessions that make one at once each keep theirs, so every
376// row any of them signed stays readable, and all of them sign with the same one from then on.
377async function secretsOf($) {
378 const read = async () => {
379 const keys = (await $.store.keys()).filter((key) => key.startsWith(SECRET_PREFIX)).sort()
380 const values = await Promise.all(keys.map((key) => $.store.get(key)))
381 return values.filter((value) => typeof value === 'string')
382 }
383 const secrets = await read()
384 if (secrets.length > 0) return secrets
385 const made = hex(crypto.getRandomValues(new Uint8Array(16)))
386 await $.store.set(SECRET_PREFIX + made, made)
387 const kept = await read()
388 return kept.length > 0 ? kept : [made]
389}
390
391async function ref(secret, text) {
392 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret + '\n' + text))
393 return hex(new Uint8Array(digest)).slice(0, 8)
394}
395
396function hex(bytes) {
397 return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
398}
399
400function isRecord(value) {
401 return (
402 value != null &&
403 typeof value.at === 'number' &&
404 Array.isArray(value.told) &&
405 value.told.every((r) => r != null && typeof r.key === 'string' && typeof r.text === 'string')
406 )
407}
408
409// Deletes the records of conversations not loaded for STALE_MS
410async function prune($) {
411 const now = await $.clock.now()
412 const keys = (await $.store.keys()).filter((key) => key.startsWith(RECORD_PREFIX))
413 for (const key of keys) {
414 const record = await $.store.get(key)
415 if (!isRecord(record) || now - record.at > STALE_MS) await $.store.delete(key)
416 }
417}
418
419// Notices of one scope that read the same are told once
420function toldKey(notice) {
421 return (notice.repo === null ? 'all' : 'repo') + '\n' + notice.text
422}
423
424function fingerprint(message) {
425 const ids = [...message.toolUses, ...(message.toolResults ?? [])].map((t) => t.tool_use_id)
426 return message.role + '\n' + ids.join(' ') + '\n' + message.text
427}
428
429// Whether now is before carried on: before's messages, less the oldest ones the capped window
430// dropped, start it, so nothing before held was rewound or replaced
431function continues(before, now) {
432 if (before.length === 0) return true
433 // Where before's newest message sits in now, tried from the newest so a repeated one doesn't hide
434 // a longer match
435 for (let end = now.length - 1; end >= 0; end--) {
436 if (now[end] !== before[before.length - 1]) continue
437 const dropped = before.length - 1 - end
438 // Only a full window drops its oldest messages
439 if (dropped < 0 || (dropped > 0 && now.length < WINDOW)) continue
440 if (now.slice(0, end + 1).every((fp, i) => fp === before[dropped + i])) return true
441 }
442 return false
443}
444
445// Names a repository the same in each of its worktrees and clones: the origin remote as
446// host[:port]/path, whether it is spelled as a URL (https://, ssh://) or scp-style (git@host:path),
447// with or without a user or .git. The path keeps its case, which some servers tell apart. With no
448// remote, or a local one (a path or file://, whose spelling may be relative), the main working
449// tree's path.
450function repoKeyOf(repo) {
451 if (!repo) return null
452 const remote = repo.remote?.trim() ?? ''
453 // A host is a name or a bracketed IPv6 address
454 const url = /^(?!file:)[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?(\[[^\]]+\]|[^/:]+)(:\d+)?\/(.+)$/i.exec(remote)
455 const scp = url ? null : /^(?:[^@/]+@)?(\[[^\]]+\]|[^/:]+):(?!\/\/)(.+)$/.exec(remote)
456 const parts = url ? [url[1] + (url[2] ?? ''), url[3]] : scp ? [scp[1], scp[2]] : null
457 if (!parts) return 'path:' + repo.root
458 return parts[0].toLowerCase() + '/' + parts[1].replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')
459}
460
461function isNotice(value) {
462 return (
463 value != null &&
464 typeof value.text === 'string' &&
465 typeof value.postedAt === 'number' &&
466 (value.repo === null || typeof value.repo === 'string')
467 )
468}
469
470function label(notice, now) {
471 return 'notice' + (notice.repo === null ? ' (all)' : '') + ' ' + ago(now - notice.postedAt) + ':'
472}
473
474function ago(ms) {
475 const minutes = Math.max(0, Math.floor(ms / 60_000))
476 if (minutes < 60) return minutes + 'm'
477 const hours = Math.floor(minutes / 60)
478 if (hours < 24) return hours + 'h'
479 return Math.floor(hours / 24) + 'd'
480}
481types/index.d.ts 10 lines1// What version 0.1 kept of the notices it passed on to the model: an id, and the text. Read once
2// per session.start to recognise that version's unsigned rows; never written.
3export type NoticeBoardKnown = { id: string; text: string }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'notice-board': { known: NoticeBoardKnown[] }
8 }
9}
10