Discord cards through a webhook, only when you're away: results with plots, finished tasks, failed jobs, questions waiting for you. Silent while you work with…

Discord cards through a webhook, for when you're not at the terminal: results with their plots, finished tasks, failed jobs, questions waiting for you. While you're working with Claude, nothing is sent.
: drop <<'EOF' + one JSON op + EOF. The mod answers it itself, so nothing runs and no permission is asked. It replies sent or held. The rules ride Claude's first prompt; the discord-drop skill has templates for eval results, paper figures, sweeps, data checks, reproducibility and more.key is edited in place as work advances (a bar and an ETA Discord counts down), without a sound. Its end is a new message that does notify.jobkit.notify() isn't repeated./scratch purge (the first Tuesday), your usage there, folder by folder.The webhook URL never appears in a command or the transcript: curl reads it on stdin.
Create a webhook (channel → Edit Channel → Integrations → Webhooks), then either:
export DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/…, or~/.claude/discord-drop/webhook with chmod 600.With the variable set, the mod copies it to that file (mode 600), so jobs on other nodes find it. /drop test sends a sample card; /drop status says where things stand.
~/.claude/discord-drop/| File | What |
|---|---|
webhook | the URL, mode 600 |
config.json | the away threshold, for jobs |
presence.json | each session's last message from you, so a job on another node knows whether you're here |
out/ | charts and long texts sent as files; cleared after 7 days |
/plugin → discord-drop → configure: minutes before you count as away (30), before a waiting prompt is sent (5), the safety net, the hand-off before an allocation ends (30 min), the purge warning (3 days, 0 for off), and the language of the mod's own cards (es or en).
hooks/register.ts 596 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { alertDrop, lossChart } from './bridge'
4import type { PaceJob, PaceJobs } from './bridge'
5import { chartSvg, ema, PALETTE } from './chart'
6import { cut, dropFrom, fmtDur, isAway, latestUserAt, MAX_FILE_BYTES, nextPurge, OP_LINE, opFrom, presenceWith, routeFor, TOOL } from './drop'
7import type { Drop, Field, Kind, Route } from './drop'
8import { attachments, baseOf, card, curlConfig, failure, messageUrl, parseCurl, patchArgv, payload, postArgv, postUrl, STRINGS, tookField } from './embed'
9import type { Lang, Strings } from './embed'
10
11type Raw = Record<string, unknown>
12type Sent = { ok: boolean; message: string; id?: string }
13
14// what a `: drop` call reads back, in the Bash tool's own shape
15const shell = (stdout: string) => ({ result: { stdout, stderr: '', interrupted: false } })
16const URL_RE = /https?:\/\/\S+/g
17const MONTHS: Record<Lang, string[]> = {
18 es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
19 en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
20}
21const KEEP_SESSIONS = 30
22// turns the safety net speaks for: the user's own, and background work they started finishing; not
23// another plugin's message (a pace-line alert already has its card)
24const NET_ORIGINS = new Set(['composer', 'bridge', 'task-notification', 'auto-continuation'])
25
26const rules = (awayMinutes: number) => `# Discord cards (discord-drop)
27The user reads Discord on their phone. A card reaches them only when they're away (no message from them to this session for ${awayMinutes} min) or when they asked for one; while they're working with you nothing is sent. Send a card with ${TOOL}; it runs nothing and needs no permission. The mod answers "sent", "progress updated", or "held" (they're here: don't mention it).
28- Starting something that will take a while (a download, a long change, a training or eval run, a batch of figures): say once, in the user's language, that if they're away when it finishes you'll message them on Discord.
29- When it finishes, one drop: kind "result" for numbers or figures, "done" otherwise, "urgent" for a failure that needs them, "needs-you" when you stopped to ask. Result first, in the user's language, one phone screen: what finished; key numbers in fields (metric | baseline | Δ); the PR or run link as url; what they need to decide. The main plot as image, other files (≤10, ≤10 MB each) in files, absolute paths.
30- Long work you can follow: kind "progress" with a key, progress 0-1 and eta (unix seconds); the same key edits the same card without a sound; final:true closes it and announces the end.
31- "force": true only when the user asked to be messaged.
32- pace-line's batch-job alerts already reach Discord through this mod: drop about a job only to add something (a diagnosis and a fix).
33- Never write the webhook URL in a command. The discord-drop skill has card templates (eval, paper figures, sweeps, datasets, reproducibility).
34: drop <<'EOF'
35{"kind":"result","title":"Eval COCO val2017","text":"mAP 43.0, +1.8 over the baseline","fields":[{"name":"mAP","value":"43.0"},{"name":"baseline","value":"41.2"},{"name":"Δ","value":"+1.8"}],"image":"/abs/path/pr_curve.png"}
36EOF`
37
38const handoffPrompt = (left: string, job: string | null) =>
39 `[discord-drop: this allocation${job ? ` (Slurm job ${job})` : ''} ends in ${left} and the user is away; not typed by the user]
40Send one \`: drop\` now with kind "urgent" and "force": true: what you were doing, where you left it (files, branch, uncommitted changes, running jobs) and how to pick it up in a new session. Then carry on if there is time.`
41
42/** A Drop of the mod's own: every field not given left empty. */
43const own = (d: Partial<Drop> & Pick<Drop, 'kind' | 'title'>): Drop => ({
44 text: '', fields: [], image: null, files: [], url: null, key: null, progress: null, eta: null, final: false, force: false, author: null, footer: null, ...d,
45})
46
47/** What a waiting tool call is about, for the card. */
48function describe(tool: string, input: unknown): string {
49 const o = (input && typeof input === 'object' ? input : {}) as Raw
50 if (tool === 'Bash' && typeof o.command === 'string') return `\`${tool}\`\n\`\`\`\n${cut(o.command, 900)}\n\`\`\``
51 for (const k of ['file_path', 'path', 'url', 'notebook_path']) if (typeof o[k] === 'string') return `\`${tool}\` → \`${cut(o[k] as string, 300)}\``
52 return `\`${tool}\`\n\`\`\`json\n${cut(JSON.stringify(o), 600)}\n\`\`\``
53}
54
55/** An AskUserQuestion's questions and options, numbered. */
56function questionsText(input: unknown, s: Strings): string {
57 const qs = Array.isArray((input as Raw | undefined)?.questions) ? ((input as Raw).questions as Raw[]) : []
58 const out = qs.slice(0, 4).map(q => {
59 const opts = Array.isArray(q.options) ? (q.options as Raw[]) : []
60 const lines = opts.map((o, i) => `${i + 1}. **${String(o.label ?? '')}**${o.description ? ` — ${cut(String(o.description), 140)}` : ''}`)
61 return [`**${cut(String(q.question ?? ''), 300)}**`, ...lines].join('\n')
62 })
63 return [...out, `-# ${s.answerInTerminal}`].join('\n\n')
64}
65
66// ── state: the settings, what this load read, what the session knows of the person ──
67
68const cfg = {
69 awayMinutes: 30,
70 needsYouMinutes: 5,
71 safetyNet: true,
72 sessionEndMinutes: 30,
73 purgeWarnDays: 3,
74 lang: 'es' as Lang,
75}
76const S = () => STRINGS[cfg.lang]
77
78const env = {
79 user: '',
80 host: 'host',
81 project: null as string | null,
82 sessionId: 'session',
83 /** ~/.claude/discord-drop */
84 dir: '',
85 url: null as string | null,
86 ownJob: null as string | null,
87 endsAt: null as number | null,
88 tzMinutes: 0,
89}
90
91/** Kept in the store, so a reload of the mod doesn't forget it. */
92const st = {
93 lastUserAt: null as number | null,
94 lastSentAt: 0,
95 safetyFired: false,
96 bridgeAfter: 0,
97 handoff: 'none' as 'none' | 'asked' | 'done',
98 handoffAskedAt: 0,
99}
100let hasToldRules = false
101let turnOrigin = ''
102let lastAssistant = ''
103let waiting: { timer: Timer; tool: string } | null = null
104
105const sessionKey = () => `s:${env.sessionId}`
106
107async function persist($: EngineInterface): Promise<void> {
108 await $.store.set(sessionKey(), { ...st })
109}
110
111async function readText($: EngineInterface, path: string): Promise<string | null> {
112 try {
113 return await $.fs.read(path)
114 } catch {
115 return null
116 }
117}
118
119async function readJson($: EngineInterface, path: string): Promise<unknown> {
120 const text = await readText($, path)
121 try {
122 return text ? (JSON.parse(text) as unknown) : null
123 } catch {
124 return null
125 }
126}
127
128async function exists($: EngineInterface, path: string): Promise<boolean> {
129 try {
130 return await $.fs.exists(path)
131 } catch {
132 return false
133 }
134}
135
136/** The webhook: DISCORD_WEBHOOK_URL, else ~/.claude/discord-drop/webhook. */
137async function webhook($: EngineInterface): Promise<string | null> {
138 if (env.url) return env.url
139 return env.dir ? (await readText($, `${env.dir}/webhook`))?.trim() || null : null
140}
141
142// ── talking to Discord ──
143
144/** Runs curl against the webhook, the URL on stdin; one retry after a rate limit. */
145async function call($: EngineInterface, argv: string[], target: string): Promise<Sent> {
146 for (let attempt = 0; attempt < 2; attempt++) {
147 let r
148 try {
149 r = await $.process.run(argv, { stdin: curlConfig(target), timeoutMs: 90_000 })
150 } catch (err) {
151 return { ok: false, message: `discord-drop: curl did not run (${String(err).replace(URL_RE, '<url>').slice(0, 160)}).` }
152 }
153 const { status, body } = parseCurl(r.stdout)
154 if (status >= 200 && status < 300) return { ok: true, message: 'drop: sent', id: typeof body?.id === 'string' ? body.id : undefined }
155 if (status === 429 && attempt === 0) {
156 const wait = Number(body?.retry_after)
157 await $.clock.sleep(Math.min(10_000, (Number.isFinite(wait) ? wait : 1) * 1000))
158 continue
159 }
160 return { ok: false, message: failure(status, body, r.stderr) }
161 }
162 return { ok: false, message: 'discord-drop: Discord kept rate-limiting the card.' }
163}
164
165/** Posts a card as a new message; `isSilent` lands it without a notification. */
166async function post($: EngineInterface, drop: Drop, isSilent: boolean): Promise<Sent> {
167 const url = await webhook($)
168 if (!url) return { ok: false, message: 'discord-drop: no webhook. Set DISCORD_WEBHOOK_URL, or put the URL alone in ~/.claude/discord-drop/webhook (chmod 600).' }
169 for (const p of [drop.image, ...drop.files]) {
170 if (!p) continue
171 let info
172 try {
173 info = await $.fs.stat(p)
174 } catch {
175 return { ok: false, message: `discord-drop: ${p} does not exist.` }
176 }
177 if (info.kind !== 'file') return { ok: false, message: `discord-drop: ${p} is not a file.` }
178 if (info.size > MAX_FILE_BYTES) {
179 return { ok: false, message: `discord-drop: ${p} is ${(info.size / 1048576).toFixed(1)} MB; Discord takes 10 MB per file. Shrink it (a smaller PNG, ffmpeg for video) and send again.` }
180 }
181 }
182 const now = await $.clock.now()
183 const ctx = { host: env.host, project: env.project, now, lang: cfg.lang }
184 let a = attachments(drop)
185 let built = card(drop, { ...ctx, image: a.image })
186 if (built.isCut && env.dir) {
187 // the text in full rides along as a file
188 const full = `${env.dir}/out/${(drop.title || 'drop').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 40)}-${now}.md`
189 await $.fs.write(full, `# ${drop.title}\n\n${drop.text}\n`)
190 a = attachments(drop, [full])
191 built = card(drop, { ...ctx, image: a.image })
192 }
193 const uploads = [...(a.image ? [a.image] : []), ...a.files]
194 const r = await call($, postArgv(payload(built.embed, uploads, isSilent), uploads), postUrl(baseOf(url)))
195 return r.ok && isSilent ? { ...r, message: 'drop: sent (silent)' } : r
196}
197
198/** Rewrites a message the webhook sent: an edit makes no sound, whoever is looking. */
199async function edit($: EngineInterface, id: string, drop: Drop): Promise<Sent> {
200 const url = await webhook($)
201 if (!url) return { ok: false, message: 'discord-drop: no webhook.' }
202 const { embed } = card({ ...drop, image: null }, { host: env.host, project: env.project, now: await $.clock.now(), lang: cfg.lang, image: null })
203 return call($, patchArgv(JSON.stringify({ embeds: [embed], allowed_mentions: { parse: [] } })), messageUrl(baseOf(url), id))
204}
205
206const held = (now: number) =>
207 `drop: held. The user is here (last message ${st.lastUserAt === null ? '?' : fmtDur((now - st.lastUserAt) / 1000)} ago), so nothing was sent; tell them in the terminal instead.`
208
209/** A progress card: created quietly when the user is away, edited in place, announced at the end. */
210async function progress($: EngineInterface, drop: Drop, away: boolean, now: number): Promise<string> {
211 const storeKey = `progress:${drop.key}`
212 const saved = (await $.store.get(storeKey)) as { id?: string } | undefined
213 if (drop.final) {
214 if (saved?.id) await edit($, saved.id, { ...drop, progress: 1 })
215 await $.store.delete(storeKey)
216 const endKind: Kind = drop.fields.length || drop.image ? 'result' : 'done'
217 if (routeFor(endKind, drop.force, away) === 'hold') return saved?.id ? 'drop: progress card closed; the user is here, so the end was not announced.' : held(now)
218 const r = await post($, { ...drop, kind: endKind, progress: null, eta: null, key: null }, false)
219 if (r.ok) {
220 st.lastSentAt = now
221 await persist($)
222 }
223 return r.ok ? 'drop: progress closed and the end announced' : r.message
224 }
225 if (saved?.id) {
226 const r = await edit($, saved.id, drop)
227 return r.ok ? 'drop: progress updated' : r.message
228 }
229 if (routeFor('progress', drop.force, away) === 'hold') return held(now)
230 const r = await post($, drop, true)
231 if (r.ok && r.id) await $.store.set(storeKey, { id: r.id, at: now })
232 return r.ok ? 'drop: progress card created (silent); later drops with this key edit it' : r.message
233}
234
235/** Delivers a drop by the rule (or by the route given) and says what happened. */
236async function send($: EngineInterface, drop: Drop, forced?: Route): Promise<string> {
237 try {
238 const now = await $.clock.now()
239 const away = isAway(st.lastUserAt, now, cfg.awayMinutes)
240 if (drop.kind === 'progress' && drop.key && !forced) return await progress($, drop, away, now)
241 const route = forced ?? routeFor(drop.kind, drop.force, away)
242 if (route === 'hold') return held(now)
243 const r = await post($, drop, route === 'silent')
244 if (r.ok) {
245 st.lastSentAt = now
246 await persist($)
247 }
248 return r.message
249 } catch (err) {
250 return `discord-drop: the card failed (${String(err).replace(URL_RE, '<url>').slice(0, 200)}).`
251 }
252}
253
254// ── the person ──
255
256/** A message from the user: they're here now, in this session and for jobs reading presence.json. */
257async function heard($: EngineInterface): Promise<void> {
258 const now = await $.clock.now()
259 st.lastUserAt = now
260 st.safetyFired = false
261 disarm()
262 await persist($)
263 if (!env.dir) return
264 try {
265 const file = `${env.dir}/presence.json`
266 await $.fs.write(file, `${JSON.stringify(presenceWith(await readJson($, file), env.sessionId, now, env.host))}\n`)
267 } catch {
268 // jobs then read an older presence and send rather than hold
269 }
270}
271
272/** True when the user wrote to any session within the away time. */
273async function anyoneHere($: EngineInterface, now: number): Promise<boolean> {
274 const shared = env.dir ? latestUserAt(await readJson($, `${env.dir}/presence.json`)) : null
275 const latest = Math.max(st.lastUserAt ?? 0, shared ?? 0)
276 return !isAway(latest || null, now, cfg.awayMinutes)
277}
278
279/** In `needsYouMinutes`, the question or the permission prompt goes to Discord, unless answered first. */
280function arm($: EngineInterface, tool: string, title: string, text: string): void {
281 disarm()
282 const armedAt = st.lastUserAt
283 const timer = $.clock.after(cfg.needsYouMinutes * 60_000, () => {
284 if (waiting?.timer !== timer) return
285 waiting = null
286 if (st.lastUserAt !== armedAt) return
287 void send($, own({ kind: 'needs-you', title, text }), 'ping')
288 })
289 waiting = { timer, tool }
290}
291
292function disarm(tool?: string): void {
293 if (!waiting || (tool !== undefined && waiting.tool !== tool)) return
294 waiting.timer.cancel()
295 waiting = null
296}
297
298// ── jobs, the allocation, scratch ──
299
300async function renderChart($: EngineInterface, job: PaceJob): Promise<string | null> {
301 const svg = lossChart(job)
302 if (!svg || !env.dir) return null
303 return toPng($, svg, `${env.dir}/out/${job.jobId}-loss`)
304}
305
306/** The SVG at `${base}.svg`, drawn to `${base}.png` at twice its size. */
307async function toPng($: EngineInterface, svg: string, base: string): Promise<string | null> {
308 try {
309 await $.fs.write(`${base}.svg`, svg)
310 const r = await $.process.run(['rsvg-convert', '-z', '2', '-o', `${base}.png`, `${base}.svg`], { timeoutMs: 20_000 })
311 return r.exitCode === 0 && (await exists($, `${base}.png`)) ? `${base}.png` : null
312 } catch {
313 return null
314 }
315}
316
317/** pace-line's jobs and alerts in this session, or undefined (not installed, not on a cluster). */
318async function paceJobs($: EngineInterface): Promise<PaceJobs | undefined> {
319 try {
320 return ((await $.state.get({ plugin: 'pace-line', key: 'jobs' } as never)) as { value?: unknown } | undefined)?.value as PaceJobs | undefined
321 } catch {
322 return undefined
323 }
324}
325
326/** pace-line's new alerts, as cards, when nobody is at a terminal. */
327async function bridge($: EngineInterface): Promise<void> {
328 const jobs = await paceJobs($)
329 const fresh = (jobs?.alerts ?? []).filter(a => a.at > st.bridgeAfter).sort((a, b) => a.at - b.at)
330 if (!jobs || !fresh.length) return
331 st.bridgeAfter = fresh[fresh.length - 1]!.at
332 await persist($)
333 const now = await $.clock.now()
334 // here: pace-line's toasts and Claude's own reply tell them
335 if (await anyoneHere($, now)) return
336 for (const a of fresh) {
337 const job = jobs.jobs.find(j => j.id === a.job)
338 // the job reported itself through jobkit.notify()
339 if (a.kind === 'ended' && job?.workDir && (await exists($, `${job.workDir}/runs/${job.jobId}/notified.json`))) continue
340 const image = a.kind === 'ended' && job ? await renderChart($, job) : null
341 const r = await post($, alertDrop(a, job, cfg.lang, image), false)
342 if (r.ok) st.lastSentAt = now
343 }
344 await persist($)
345}
346
347/** Before the allocation ends: Claude hands off, or the mod does. */
348async function watchEnd($: EngineInterface): Promise<void> {
349 if (env.endsAt === null || st.handoff === 'done' || !cfg.sessionEndMinutes) return
350 const now = await $.clock.now()
351 const left = env.endsAt - now
352 if (left <= 0) return
353 const window = cfg.sessionEndMinutes * 60_000
354 if (st.handoff === 'none' && left <= window) {
355 // here: pace-line's countdown already warns them
356 if (!isAway(st.lastUserAt, now, cfg.awayMinutes)) {
357 st.handoff = 'done'
358 await persist($)
359 return
360 }
361 st.handoff = 'asked'
362 st.handoffAskedAt = now
363 await persist($)
364 try {
365 await $.prompt.submit({ text: handoffPrompt(fmtDur(left / 1000), env.ownJob) })
366 } catch {
367 // no session to ask: the fallback below sends it
368 }
369 return
370 }
371 if (st.handoff === 'asked' && left <= window / 2) {
372 st.handoff = 'done'
373 await persist($)
374 if (st.lastSentAt >= st.handoffAskedAt) return
375 await send($, own({ kind: 'urgent', title: S().sessionEnds(fmtDur(left / 1000)), text: `${S().sessionEndsText}\n\n${cut(lastAssistant || '—', 1500)}` }), 'ping')
376 }
377}
378
379/** Days before the monthly /scratch purge: what lives there, once per purge. */
380async function checkPurge($: EngineInterface): Promise<void> {
381 if (!cfg.purgeWarnDays || !env.user) return
382 const now = await $.clock.now()
383 const p = nextPurge(now, env.tzMinutes)
384 if (p.daysLeft > cfg.purgeWarnDays) return
385 const id = `purge:${p.year}-${p.month}-${p.day}`
386 const scratch = `/scratch/${env.user}`
387 if ((await $.store.get(id)) || !(await exists($, scratch))) return
388 await $.store.set(id, now)
389 let rows: [number, string][] = []
390 try {
391 const du = await $.process.run(['sh', '-c', 'du -s -B1G -- "$1"/* 2>/dev/null | sort -rn | head -n 12', 'sh', scratch], { timeoutMs: 300_000 })
392 rows = du.stdout.split('\n').map(l => /^(\d+)\s+(.+)$/.exec(l.trim())).filter((m): m is RegExpExecArray => !!m).map(m => [Number(m[1]), m[2]!])
393 } catch {
394 // the card goes without sizes
395 }
396 const total = rows.reduce((t, r) => t + r[0], 0)
397 const date = cfg.lang === 'es' ? `${p.day} ${MONTHS.es[p.month - 1]}` : `${MONTHS.en[p.month - 1]} ${p.day}`
398 if (await anyoneHere($, now)) {
399 $.ui.toast(`discord-drop: ${S().purgeTitle(date)}${total ? ` · ${total} GB in ${scratch}` : ''}`, { timeoutMs: 30_000 })
400 return
401 }
402 const fields: Field[] = rows.map(([gb, path]) => ({ name: path.split('/').pop() || path, value: `${gb} GB`, inline: true }))
403 await send($, own({ kind: 'urgent', title: S().purgeTitle(date), text: `${S().purgeText}\n-# \`${scratch}\`${total ? ` · ${total} GB` : ''}`, fields }), 'ping')
404}
405
406// ── /drop ──
407
408async function status($: EngineInterface): Promise<string> {
409 const now = await $.clock.now()
410 const url = env.url ? 'set (DISCORD_WEBHOOK_URL)' : (await webhook($)) ? `set (${env.dir}/webhook)` : 'missing: set DISCORD_WEBHOOK_URL'
411 const you = st.lastUserAt === null
412 ? 'no message from you yet in this session (counts as away)'
413 : `${isAway(st.lastUserAt, now, cfg.awayMinutes) ? 'away' : 'here'}: your last message was ${fmtDur((now - st.lastUserAt) / 1000)} ago`
414 const p = nextPurge(now, env.tzMinutes)
415 const jobs = await paceJobs($)
416 return [
417 'discord-drop',
418 `· webhook: ${url}`,
419 `· you: ${you}`,
420 `· cards reach Discord after ${cfg.awayMinutes} min away; a question or permission prompt after ${cfg.needsYouMinutes} min`,
421 env.endsAt !== null ? `· this allocation ends in ${fmtDur((env.endsAt - now) / 1000)}; hand-off ${cfg.sessionEndMinutes} min before` : '· not inside a Slurm allocation',
422 jobs ? `· pace-line: ${jobs.jobs.length} batch job${jobs.jobs.length === 1 ? '' : 's'} watched; their alerts become cards while you're away` : '· pace-line: not watching jobs in this session (no alerts to forward)',
423 cfg.purgeWarnDays ? `· next /scratch purge: ${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')} (in ${p.daysLeft} d)` : '· purge warning off',
424 '/drop test sends a sample card.',
425 ].join('\n')
426}
427
428/** A sample card with a made-up loss chart, sent whoever is here. */
429async function test($: EngineInterface): Promise<string> {
430 const xs = Array.from({ length: 300 }, (_, i) => i * 20)
431 const ys = xs.map((s, i) => 2.2 * Math.exp(-s / 1800) + 0.3 + 0.08 * Math.sin(i * 1.7))
432 const svg = chartSvg({ title: 'demo-run (0000)', xName: 'step', series: [
433 { label: 'loss', xs, ys, color: PALETTE[0]!, isFaint: true },
434 { label: 'loss (EMA)', xs, ys: ema(ys), color: PALETTE[0]! },
435 ] })
436 const image = svg && env.dir ? await toPng($, svg, `${env.dir}/out/test`) : null
437 const fields: Field[] = [
438 { name: 'mAP', value: '43.0', inline: true }, { name: 'baseline', value: '41.2', inline: true }, { name: 'Δ', value: '+1.8', inline: true },
439 ]
440 const r = await post($, own({ kind: 'result', title: S().test, text: `${S().testText}\n\`\`\`diff\n+ mAP 43.0 (+1.8)\n- latency 41 ms (+3 ms)\n\`\`\``, fields, image, force: true }), false)
441 return r.ok ? 'Sent a sample card to Discord.' : r.message
442}
443
444// ── start-up ──
445
446async function start($: EngineInterface, cwd: string): Promise<void> {
447 const home = (await $.env.get('HOME')) ?? ''
448 env.user = (await $.env.get('USER')) ?? (await $.env.get('LOGNAME')) ?? ''
449 env.host = (await $.env.get('SLURMD_NODENAME')) ?? (await $.env.get('HOSTNAME')) ?? 'host'
450 env.url = (await $.env.get('DISCORD_WEBHOOK_URL'))?.trim() || null
451 env.ownJob = (await $.env.get('SLURM_JOB_ID')) ?? null
452 const end = Number(await $.env.get('SLURM_JOB_END_TIME'))
453 env.endsAt = Number.isFinite(end) && end > 0 ? end * 1000 : null
454 env.sessionId = await $.session.id()
455 env.project = cwd.split('/').filter(Boolean).pop() ?? null
456 env.dir = home ? `${home}/.claude/discord-drop` : ''
457 try {
458 const z = (await $.process.run(['date', '+%z'], { timeoutMs: 5_000 })).stdout.trim()
459 const m = /^([+-])(\d\d)(\d\d)$/.exec(z)
460 if (m) env.tzMinutes = (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))
461 } catch {
462 // UTC dates then
463 }
464
465 const saved = (await $.store.get(sessionKey())) as Raw | undefined
466 const now = await $.clock.now()
467 if (saved) {
468 st.lastUserAt = typeof saved.lastUserAt === 'number' ? saved.lastUserAt : null
469 st.lastSentAt = typeof saved.lastSentAt === 'number' ? saved.lastSentAt : 0
470 st.safetyFired = saved.safetyFired === true
471 st.handoff = saved.handoff === 'asked' || saved.handoff === 'done' ? saved.handoff : 'none'
472 st.handoffAskedAt = typeof saved.handoffAskedAt === 'number' ? saved.handoffAskedAt : 0
473 }
474 // alerts raised before this load were pace-line's to tell, not ours to repeat
475 st.bridgeAfter = typeof saved?.bridgeAfter === 'number' ? saved.bridgeAfter : now
476 await persist($)
477 const old = (await $.store.keys()).filter(k => k.startsWith('s:') && k !== sessionKey())
478 for (const k of old.slice(0, Math.max(0, old.length - (KEEP_SESSIONS - 1)))) await $.store.delete(k)
479
480 if (!env.dir) return
481 try {
482 // jobs on other nodes read the same threshold, and the webhook from the file
483 await $.fs.write(`${env.dir}/config.json`, `${JSON.stringify({ awayMinutes: cfg.awayMinutes, language: cfg.lang })}\n`)
484 if (env.url && (await readText($, `${env.dir}/webhook`))?.trim() !== env.url) {
485 await $.process.run(['sh', '-c', 'umask 077 && mkdir -p "$1" && cat > "$1/webhook" && chmod 600 "$1/webhook"', 'sh', env.dir], { stdin: `${env.url}\n`, timeoutMs: 5_000 })
486 }
487 await $.process.run(['find', `${env.dir}/out`, '-type', 'f', '-mtime', '+7', '-delete'], { timeoutMs: 10_000 })
488 } catch {
489 // a card still goes from this session
490 }
491}
492
493export const register: Register = (on, options) => {
494 const n = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : d)
495 cfg.awayMinutes = n(options.awayMinutes, 30)
496 cfg.needsYouMinutes = n(options.needsYouMinutes, 5)
497 cfg.safetyNet = options.safetyNet !== false
498 cfg.sessionEndMinutes = n(options.sessionEndMinutes, 30)
499 cfg.purgeWarnDays = n(options.purgeWarnDays, 3)
500 cfg.lang = options.language === 'en' ? 'en' : 'es'
501
502 on('session.start', async ($, e, next) => {
503 const started = await next(e)
504 await start($, e.cwd)
505 $.clock.every(15_000, () => void bridge($).catch(() => undefined))
506 $.clock.every(60_000, () => void watchEnd($).catch(() => undefined))
507 $.clock.every(6 * 3600_000, () => void checkPurge($).catch(() => undefined))
508 void checkPurge($).catch(() => undefined)
509 await $.command.register({ name: 'drop', description: 'Discord cards: where things stand, or a sample card', argumentHint: 'status | test' })
510 return started
511 })
512
513 // the rules ride the first prompt of a session (and of a reload, and after a compaction):
514 // the system-prompt route does not reach the model on every host
515 on('prompt.submit', async ($, e, next) => {
516 const kind = e.origin.kind
517 turnOrigin = kind
518 if (kind === 'composer' || kind === 'bridge') await heard($)
519 if (hasToldRules || (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk')) return next(e)
520 hasToldRules = true
521 return next({ ...e, context: [...(e.context ?? []), rules(cfg.awayMinutes)] })
522 }).catch(($, e, next) => next(e))
523
524 on('session.compact', async ($, e, next) => {
525 hasToldRules = false
526 return next(e)
527 }).catch(($, e, next) => next(e))
528
529 on('tool.call', async ($, e, next) => {
530 const input = e as unknown as Raw
531 if (e.tool === 'Bash' && typeof input.command === 'string' && OP_LINE.test(input.command)) {
532 const raw = opFrom(input.command)
533 if (typeof raw === 'string') return { deny: raw }
534 const drop = dropFrom(raw)
535 if (typeof drop === 'string') return { deny: drop }
536 return shell(await send($, drop))
537 }
538 if (!e.agentId && (e.tool === 'AskUserQuestion' || e.tool === 'ExitPlanMode')) {
539 const isPlan = e.tool === 'ExitPlanMode'
540 arm($, e.tool, isPlan ? S().planReady : S().needsAnswer, isPlan ? S().answerInTerminal : questionsText(input, S()))
541 try {
542 return await next(e)
543 } finally {
544 disarm(e.tool)
545 }
546 }
547 try {
548 return await next(e)
549 } finally {
550 // a permission prompt for this call was answered
551 disarm(e.tool)
552 }
553 }).catch(($, e, next) => next(e))
554
555 // a permission dialog really shown (no hook settled it): the clock starts
556 on('classic.PermissionRequest', async ($, e, next) => {
557 const answer = await next(e)
558 if (answer.decision) return answer
559 arm($, e.tool_name, S().needsPermission, describe(e.tool_name, e.tool_input))
560 return answer
561 }).catch(($, e, next) => next(e))
562
563 on('classic.PermissionDenied', async ($, e, next) => {
564 disarm()
565 return next(e)
566 }).catch(($, e, next) => next(e))
567
568 on('turn.complete', async ($, e, next) => {
569 if (!e.agentId) disarm()
570 return next(e)
571 })
572
573 // the safety net: a long stretch of work ended while they're away, and nothing told them
574 on('classic.Stop', async ($, e, next) => {
575 const result = await next(e)
576 if (e.agent_id) return result
577 if (e.last_assistant_message) lastAssistant = e.last_assistant_message
578 if (!cfg.safetyNet || e.stop_hook_active || result.block || (e.background_tasks?.length ?? 0) > 0 || !NET_ORIGINS.has(turnOrigin)) return result
579 const since = st.lastUserAt
580 if (since === null || st.safetyFired || st.lastSentAt >= since) return result
581 const now = await $.clock.now()
582 if (!isAway(since, now, cfg.awayMinutes)) return result
583 st.safetyFired = true
584 await persist($)
585 const msg = (e.last_assistant_message ?? '').trim()
586 const asks = /\?\s*$/.test(msg)
587 await send($, own({ kind: asks ? 'needs-you' : 'done', title: asks ? S().question : S().finished, text: cut(msg, 1800), fields: [tookField(S(), (now - since) / 1000)] }))
588 return result
589 }).catch(($, e, next) => next(e))
590
591 on('command.run', { command: 'drop' }, async ($, e) => {
592 const sub = e.args.trim().split(/\s+/)[0] || 'status'
593 return { text: sub === 'test' ? await test($) : await status($) }
594 })
595}
596hooks/bridge.ts 148 lines1// pace-line's batch-job alerts as cards. pace-line watches Slurm and keeps its
2// alerts in its session state; this turns one alert and its job into a Drop,
3// and the job's metrics into a loss chart. Pure.
4
5import { chartSvg, ema, fmt, PALETTE } from './chart'
6import { cut, fmtDur } from './drop'
7import type { Drop, Field, Kind } from './drop'
8import type { Lang } from './embed'
9
10/** The part of pace-line's state read here (pace-line's types/index.d.ts has the whole). */
11export type PaceSeries = { step: number[]; value: number[] }
12export type PaceMetrics = { keys: Record<string, PaceSeries>; lastStep: number | null; totalSteps: number | null; epoch: number | null; totalEpochs: number | null }
13export type PaceJob = {
14 id: string
15 jobId: string
16 name: string
17 state: string
18 elapsedS: number | null
19 nodes: number
20 gpus: number | null
21 gpuType: string | null
22 submittedAt: number | null
23 startedAt: number | null
24 workDir: string | null
25 stdout: string | null
26 exitCode: string | null
27 lastLines: string[]
28 metrics: PaceMetrics | null
29 ckpt: { name: string; path: string } | null
30}
31export type PaceAlert = { job: string; kind: string; level: 'info' | 'warn' | 'error'; text: string; at: number }
32export type PaceJobs = { jobs: PaceJob[]; alerts: PaceAlert[] }
33
34const LABEL: Record<Lang, Record<string, string>> = {
35 es: {
36 completed: 'terminó', ended: 'falló', oom: 'sin memoria (OOM)', nccl: 'error de NCCL', traceback: 'lanzó una excepción',
37 nonFinite: 'métrica NaN/inf', silent: 'log en silencio', idle: 'GPU ociosa', nearLimit: 'cerca del límite de tiempo',
38 staleCkpt: 'sin checkpoint nuevo', pending: 'lleva mucho en cola', started: 'arrancó', resumed: 'reenviado desde checkpoint',
39 resumeStopped: 'auto-resume parado',
40 },
41 en: {
42 completed: 'completed', ended: 'failed', oom: 'out of memory', nccl: 'NCCL error', traceback: 'raised an exception',
43 nonFinite: 'metric went NaN/inf', silent: 'log silent', idle: 'GPU idle', nearLimit: 'near its time limit',
44 staleCkpt: 'no new checkpoint', pending: 'pending a long time', started: 'started', resumed: 'resubmitted from checkpoint',
45 resumeStopped: 'auto-resume stopped',
46 },
47}
48const FIELD: Record<Lang, Record<string, string>> = {
49 es: { took: 'Duración', gpus: 'GPUs', steps: 'Pasos', epoch: 'Época', ckpt: 'Checkpoint', exit: 'Salida', log: 'Log', queued: 'En cola', loss: 'loss' },
50 en: { took: 'Took', gpus: 'GPUs', steps: 'Steps', epoch: 'Epoch', ckpt: 'Checkpoint', exit: 'Exit', log: 'Log', queued: 'Queued', loss: 'loss' },
51}
52
53/** Whether a metric improves downwards: losses and errors do. */
54export function lowerIsBetter(key: string): boolean {
55 return /loss|err|wer|cer|mae|mse|rmse|perplexity|ppl/i.test(key)
56}
57
58export function lossKey(m: PaceMetrics): string | null {
59 const keys = Object.keys(m.keys)
60 return keys.find(k => k === 'loss') ?? keys.find(k => k === 'train_loss') ?? keys.find(k => /loss$/.test(k) && !/^val/.test(k)) ?? null
61}
62export const valKeys = (m: PaceMetrics) => Object.keys(m.keys).filter(k => /^val[_/]/.test(k))
63
64const finite = (s: PaceSeries) => s.value.filter(Number.isFinite)
65const last = (s: PaceSeries) => finite(s).pop() ?? null
66const best = (key: string, s: PaceSeries) => {
67 const v = finite(s)
68 return v.length ? (lowerIsBetter(key) ? Math.min(...v) : Math.max(...v)) : null
69}
70
71/** What a card of this alert is. */
72export function kindOf(a: PaceAlert, job: PaceJob | undefined): Kind {
73 if (a.kind === 'ended') return job?.state === 'COMPLETED' || a.level === 'info' ? 'result' : 'urgent'
74 if (a.kind === 'started') return 'started'
75 if (a.kind === 'pending' || a.kind === 'resumed') return 'done'
76 return 'urgent'
77}
78
79/** The card for one alert; `image` is the loss chart's PNG when there is one. */
80export function alertDrop(a: PaceAlert, job: PaceJob | undefined, lang: Lang, image: string | null): Drop {
81 const kind = kindOf(a, job)
82 const L = LABEL[lang]
83 const F = FIELD[lang]
84 const name = job?.name ?? `job ${a.job}`
85 const label = a.kind === 'ended' ? (kind === 'result' ? L.completed : `${L.ended}: ${job?.state ?? ''}`.replace(/: $/, '')) : (L[a.kind] ?? a.kind)
86 const fields: Field[] = []
87 if (job?.elapsedS !== null && job?.elapsedS !== undefined) fields.push({ name: F.took, value: fmtDur(job.elapsedS), inline: true })
88 if (job?.gpus) fields.push({ name: F.gpus, value: `${job.gpus}×${job.gpuType ?? 'GPU'}${job.nodes > 1 ? ` · ${job.nodes} nodes` : ''}`, inline: true })
89 if (a.kind === 'started' && job?.submittedAt && job.startedAt) fields.push({ name: F.queued, value: fmtDur((job.startedAt - job.submittedAt) / 1000), inline: true })
90 const m = job?.metrics
91 if (m && (kind === 'result' || kind === 'urgent')) {
92 if (m.lastStep !== null) fields.push({ name: F.steps, value: m.totalSteps ? `${m.lastStep}/${m.totalSteps}` : String(m.lastStep), inline: true })
93 if (m.epoch !== null) fields.push({ name: F.epoch, value: m.totalEpochs ? `${m.epoch}/${m.totalEpochs}` : String(m.epoch), inline: true })
94 const lk = lossKey(m)
95 const lv = lk ? ema(finite(m.keys[lk]!)).pop() : undefined
96 if (lk && lv !== undefined) fields.push({ name: lk, value: fmt(lv), inline: true })
97 for (const k of valKeys(m).slice(0, 6)) {
98 const v = last(m.keys[k]!)
99 const b = best(k, m.keys[k]!)
100 if (v !== null) fields.push({ name: k, value: b !== null && b !== v ? `${fmt(v)} · best ${fmt(b)}` : fmt(v), inline: true })
101 }
102 }
103 if (job?.ckpt && kind === 'result') fields.push({ name: F.ckpt, value: `\`${cut(job.ckpt.name, 100)}\``, inline: false })
104 if (kind === 'urgent' && job?.exitCode && job.exitCode !== '0:0') fields.push({ name: F.exit, value: job.exitCode, inline: true })
105 if (kind === 'urgent' && job?.stdout) fields.push({ name: F.log, value: `\`${cut(job.stdout, 200)}\``, inline: false })
106 const tail = kind === 'urgent' && job?.lastLines.length ? `\n\`\`\`\n${cut(job.lastLines.slice(-10).join('\n'), 1400)}\n\`\`\`` : ''
107 return {
108 kind,
109 title: `${name} ${label}`,
110 text: `-# ${cut(a.text, 300)}${tail}`,
111 fields,
112 image,
113 files: [],
114 url: null,
115 key: null,
116 progress: null,
117 eta: null,
118 final: false,
119 force: false,
120 author: job ? `${job.name} (${job.id})` : `job ${a.job}`,
121 footer: `job ${a.job}`,
122 }
123}
124
125/** The job's loss chart (train loss, its EMA, val loss with its best ringed), or null. */
126export function lossChart(job: PaceJob): string | null {
127 const m = job.metrics
128 if (!m) return null
129 const lk = lossKey(m)
130 const vk = valKeys(m).find(k => /loss/.test(k)) ?? (lk ? null : valKeys(m)[0] ?? null)
131 if (!lk && !vk) return null
132 const series = []
133 if (lk) {
134 const s = m.keys[lk]!
135 series.push({ label: lk, xs: s.step, ys: s.value, color: PALETTE[0]!, isFaint: true })
136 series.push({ label: `${lk} (EMA)`, xs: s.step, ys: ema(s.value), color: PALETTE[0]! })
137 }
138 let marker
139 if (vk) {
140 const s = m.keys[vk]!
141 series.push({ label: vk, xs: s.step, ys: s.value, color: PALETTE[1]! })
142 const b = best(vk, s)
143 const i = b === null ? -1 : s.value.indexOf(b)
144 if (i >= 0) marker = { x: s.step[i]!, y: b!, color: PALETTE[1]!, label: `best ${fmt(b!)}` }
145 }
146 return chartSvg({ title: `${job.name} (${job.id})`, xName: 'step', series, marker })
147}
148hooks/chart.ts 195 lines1// A still line chart for a card: flat colours on Discord's dark card
2// background, nice ticks, a legend, the last value labelled and the best
3// point ringed. No CSS variables or media queries: librsvg 2.50 turns it
4// into the PNG Discord shows. Pure: series in, an `<svg>` string out.
5
6const BG = '#2b2d31'
7const INK = '#f2f3f5'
8const MUTED = '#949ba4'
9const GRID = '#3f4147'
10const AXIS = '#4e5058'
11export const PALETTE = ['#3987e5', '#e8742f', '#23a55a', '#d4a72c', '#d55181', '#9085e9']
12const FONT = 'DejaVu Sans, Liberation Sans, sans-serif'
13
14export type ChartSeries = {
15 label: string
16 xs: readonly number[]
17 ys: readonly number[]
18 color: string
19 /** A faint thin line under the others (raw loss under its EMA). */
20 isFaint?: boolean
21}
22
23export type ChartSpec = {
24 title?: string
25 series: readonly ChartSeries[]
26 width?: number
27 height?: number
28 xName?: string
29 /** A point to ring and label, e.g. the best validation value. */
30 marker?: { x: number; y: number; color: string; label: string }
31 /** Log y when every value is positive and they span over two decades. */
32 allowLog?: boolean
33}
34
35const r1 = (v: number) => Math.round(v * 10) / 10
36const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
37const trim = (v: number, d: number) => v.toFixed(d).replace(/\.?0+$/, '')
38
39/** 0.000412 → "4.1e-4", 12345 → "12.3k", 0.4123 → "0.412". */
40export function fmt(v: number): string {
41 if (!Number.isFinite(v)) return String(v)
42 const a = Math.abs(v)
43 if (a === 0) return '0'
44 if (a >= 1e6) return `${trim(v / 1e6, 1)}M`
45 if (a >= 1e4) return `${trim(v / 1e3, a >= 1e5 ? 0 : 1)}k`
46 if (a >= 1000) return `${trim(v / 1e3, 2)}k`
47 if (a >= 100) return v.toFixed(0)
48 if (a >= 10) return trim(v, 1)
49 if (a >= 0.01) return trim(v, 3)
50 return v.toExponential(1)
51}
52
53/** Ticks at 1, 2, 2.5 or 5 × 10^k covering [min, max], about `count` of them. */
54export function niceTicks(min: number, max: number, count = 4): number[] {
55 if (!(max > min)) return [min]
56 const raw = (max - min) / Math.max(1, count)
57 const mag = 10 ** Math.floor(Math.log10(raw))
58 const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => s >= raw * 0.75) ?? 10 * mag
59 const out: number[] = []
60 for (let v = Math.floor(min / step) * step; v <= max + step * 1e-9; v += step) out.push(Math.round(v / step) * step)
61 if (out[out.length - 1]! < max - step * 1e-9) out.push(out[out.length - 1]! + step)
62 return out
63}
64
65/** At most about 2 × `buckets` points, keeping each bucket's first, lowest, highest and last. */
66export function decimate(xs: readonly number[], ys: readonly number[], buckets: number): { xs: number[]; ys: number[] } {
67 const pts: [number, number][] = []
68 for (let i = 0; i < xs.length; i++) if (Number.isFinite(xs[i]) && Number.isFinite(ys[i])) pts.push([xs[i]!, ys[i]!])
69 if (pts.length <= buckets * 2) return { xs: pts.map(p => p[0]), ys: pts.map(p => p[1]) }
70 const x0 = pts[0]![0]
71 const span = pts[pts.length - 1]![0] - x0 || 1
72 const keep = new Set<number>()
73 let b = -1, first = 0, lo = 0, hi = 0
74 const flush = (last: number) => { if (b >= 0) for (const i of [first, lo, hi, last]) keep.add(i) }
75 for (let i = 0; i < pts.length; i++) {
76 const k = Math.min(buckets - 1, Math.floor(((pts[i]![0] - x0) / span) * buckets))
77 if (k !== b) {
78 flush(i - 1)
79 b = k; first = i; lo = i; hi = i
80 } else {
81 if (pts[i]![1] < pts[lo]![1]) lo = i
82 if (pts[i]![1] > pts[hi]![1]) hi = i
83 }
84 }
85 flush(pts.length - 1)
86 const idx = [...keep].sort((a, c) => a - c)
87 return { xs: idx.map(i => pts[i]![0]), ys: idx.map(i => pts[i]![1]) }
88}
89
90/** Exponential moving average, the smoothing TensorBoard's slider applies. */
91export function ema(values: readonly number[], alpha = 0.9): number[] {
92 const out: number[] = []
93 let acc: number | null = null
94 for (const v of values) {
95 acc = acc === null || !Number.isFinite(acc) ? v : alpha * acc + (1 - alpha) * v
96 out.push(acc)
97 }
98 return out
99}
100
101/** The chart, or null when there is nothing to draw. */
102export function chartSvg(spec: ChartSpec): string | null {
103 const W = spec.width ?? 800
104 const H = spec.height ?? 360
105 const series = spec.series.filter(s => s.xs.some((x, i) => Number.isFinite(x) && Number.isFinite(s.ys[i])))
106 if (!series.length) return null
107
108 let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity
109 for (const s of series) {
110 for (let i = 0; i < s.xs.length; i++) {
111 const x = s.xs[i]!, y = s.ys[i]!
112 if (!Number.isFinite(x) || !Number.isFinite(y)) continue
113 xMin = Math.min(xMin, x); xMax = Math.max(xMax, x)
114 yMin = Math.min(yMin, y); yMax = Math.max(yMax, y)
115 }
116 }
117 if (xMax === xMin) { xMin -= 1; xMax += 1 }
118 const isLog = !!spec.allowLog && yMin > 0 && yMax / yMin > 100
119 let ticks: number[]
120 if (isLog) {
121 ticks = []
122 for (let v = 10 ** Math.floor(Math.log10(yMin)); v <= 10 ** Math.ceil(Math.log10(yMax)) * 1.0001; v *= 10) ticks.push(v)
123 } else {
124 if (yMax === yMin) { const pad = Math.abs(yMax) * 0.05 || 1; yMin -= pad; yMax += pad }
125 ticks = niceTicks(yMin, yMax, 5)
126 }
127 const lo = ticks[0]!
128 const hi = ticks[ticks.length - 1]!
129
130 const labelled = series.filter(s => !s.isFaint)
131 const titleH = spec.title ? 30 : 8
132 const legendH = labelled.length >= 2 ? 22 : 0
133 const top = titleH + legendH + 8
134 const left = Math.max(...ticks.map(t => fmt(t).length)) * 8 + 18
135 const right = 70
136 const bottom = 34
137 const pw = W - left - right
138 const ph = H - top - bottom
139 const sx = (x: number) => left + ((x - xMin) / (xMax - xMin)) * pw
140 const ty = (y: number) => (isLog ? Math.log10(y) : y)
141 const sy = (y: number) => top + ph - ((ty(Math.min(hi, Math.max(lo, y))) - ty(lo)) / (ty(hi) - ty(lo))) * ph
142
143 const parts: string[] = [`<rect width="${W}" height="${H}" rx="8" fill="${BG}"/>`]
144 if (spec.title) parts.push(`<text x="${left}" y="22" fill="${INK}" font-size="15" font-weight="600">${esc(spec.title)}</text>`)
145 for (const t of ticks) {
146 const y = r1(sy(t))
147 parts.push(`<line x1="${left}" x2="${r1(left + pw)}" y1="${y}" y2="${y}" stroke="${GRID}" stroke-width="1"/>`)
148 parts.push(`<text x="${r1(left - 8)}" y="${r1(y + 4)}" fill="${MUTED}" font-size="12" text-anchor="end">${fmt(t)}</text>`)
149 }
150 parts.push(`<line x1="${left}" x2="${r1(left + pw)}" y1="${r1(top + ph)}" y2="${r1(top + ph)}" stroke="${AXIS}" stroke-width="1"/>`)
151 for (const t of niceTicks(xMin, xMax, Math.max(2, Math.floor(pw / 120)))) {
152 if (t < xMin || t > xMax) continue
153 parts.push(`<text x="${r1(sx(t))}" y="${r1(top + ph + 18)}" fill="${MUTED}" font-size="12" text-anchor="middle">${esc(fmt(t))}</text>`)
154 }
155 if (spec.xName) parts.push(`<text x="${r1(left + pw)}" y="${H - 4}" fill="${MUTED}" font-size="11" text-anchor="end">${esc(spec.xName)}</text>`)
156
157 for (const s of [...series.filter(x => x.isFaint), ...labelled]) {
158 const d = decimate(s.xs, s.ys, Math.round(pw))
159 if (!d.xs.length) continue
160 const path = d.xs.map((x, i) => `${i ? 'L' : 'M'}${r1(sx(x))} ${r1(sy(d.ys[i]!))}`).join('')
161 parts.push(s.isFaint
162 ? `<path d="${path}" fill="none" stroke="${s.color}" stroke-opacity="0.35" stroke-width="1.2" stroke-linejoin="round"/>`
163 : `<path d="${path}" fill="none" stroke="${s.color}" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>`)
164 if (s.isFaint) continue
165 // The last value beside the line's end.
166 const i = d.xs.length - 1
167 const x = sx(d.xs[i]!), y = sy(d.ys[i]!)
168 // past a marker ring sitting on this very point
169 const isRinged = spec.marker !== undefined && Math.abs(sx(spec.marker.x) - x) < 6 && Math.abs(sy(spec.marker.y) - y) < 6
170 parts.push(`<circle cx="${r1(x)}" cy="${r1(y)}" r="4" fill="${s.color}" stroke="${BG}" stroke-width="2"/>`)
171 parts.push(`<text x="${r1(x + (isRinged ? 14 : 8))}" y="${r1(Math.min(top + ph, Math.max(top + 10, y + 4)))}" fill="${s.color}" font-size="12" font-weight="600">${esc(fmt(d.ys[i]!))}</text>`)
172 }
173
174 if (spec.marker && Number.isFinite(spec.marker.x) && Number.isFinite(spec.marker.y)) {
175 const x = sx(spec.marker.x), y = sy(spec.marker.y)
176 const c = spec.marker.color
177 parts.push(`<circle cx="${r1(x)}" cy="${r1(y)}" r="9" fill="none" stroke="${c}" stroke-width="1.6"/>`)
178 const above = y - top > 26
179 const anchor = x > left + pw * 0.75 ? 'end' : x < left + pw * 0.25 ? 'start' : 'middle'
180 parts.push(`<text x="${r1(x)}" y="${r1(above ? y - 15 : y + 26)}" fill="${INK}" font-size="12" text-anchor="${anchor}">${esc(spec.marker.label)}</text>`)
181 }
182
183 if (legendH) {
184 let x = left
185 const y = titleH + 12
186 for (const s of labelled) {
187 parts.push(`<line x1="${x}" x2="${x + 16}" y1="${y}" y2="${y}" stroke="${s.color}" stroke-width="3" stroke-linecap="round"/>`)
188 parts.push(`<text x="${x + 22}" y="${y + 4}" fill="${INK}" font-size="12">${esc(s.label)}</text>`)
189 x += 22 + s.label.length * 7.5 + 22
190 }
191 }
192
193 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="${FONT}">${parts.join('')}</svg>`
194}
195hooks/drop.ts 180 lines1// What a drop is and when it goes out. Pure: the op Claude writes in, a
2// validated Drop out; the delivery rule; presence; the purge calendar.
3
4export type Kind = 'done' | 'result' | 'urgent' | 'needs-you' | 'progress' | 'started'
5export const KINDS: readonly Kind[] = ['done', 'result', 'urgent', 'needs-you', 'progress', 'started']
6
7export type Field = { name: string; value: string; inline: boolean }
8
9export type Drop = {
10 kind: Kind
11 title: string
12 text: string
13 fields: Field[]
14 /** Absolute path of the picture shown inside the card. */
15 image: string | null
16 /** Absolute paths attached under the card. */
17 files: string[]
18 url: string | null
19 /** A progress card's name: the same key edits the same card. */
20 key: string | null
21 /** 0 to 1. */
22 progress: number | null
23 /** Unix seconds. */
24 eta: number | null
25 final: boolean
26 /** The user asked for it: it goes out whether they're here or not. */
27 force: boolean
28 author: string | null
29 footer: string | null
30}
31
32/** ping: a message that notifies; silent: one that doesn't; hold: nothing is sent. */
33export type Route = 'ping' | 'silent' | 'hold'
34
35export const MAX_FILES = 10
36export const MAX_FILE_BYTES = 10 * 1024 * 1024
37
38/** The shell no-op Claude sends a card with; the mod answers it, so nothing runs. */
39export const OP_LINE = /^\s*:\s+drop\b/
40export const TOOL = "a Bash call `: drop <<'EOF'` + one JSON op + `EOF`"
41
42const EXAMPLE = `: drop <<'EOF'\n{"kind":"result","title":"Eval COCO val","text":"mAP 43.0 (+1.8)","image":"/abs/pr.png"}\nEOF`
43
44type Raw = Record<string, unknown>
45
46/** The op of a `: drop` call: the JSON in its heredoc, or a refusal. */
47export function opFrom(command: string): Raw | string {
48 const doc = /<<-?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n\s*\2\s*$/.exec(command)
49 const text = (doc ? (doc[3] ?? '') : command.replace(OP_LINE, '').replace(/^\s*'([\s\S]*)'\s*$/, '$1')).trim()
50 try {
51 const op = JSON.parse(text) as unknown
52 if (op && typeof op === 'object' && !Array.isArray(op)) return op as Raw
53 } catch {
54 // falls through to the refusal
55 }
56 return `discord-drop: \`: drop\` needs one JSON op in a heredoc, e.g.\n${EXAMPLE}`
57}
58
59// the cut drops half a character left at the edge (an emoji is two UTF-16 units)
60export const cut = (s: string, max: number) => (s.length <= max ? s : `${s.slice(0, max - 1).replace(/[\uD800-\uDBFF]$/, '')}…`)
61const str = (v: unknown) => (typeof v === 'string' ? v.trim() : typeof v === 'number' && Number.isFinite(v) ? String(v) : '')
62const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) ? Number(v) : null)
63
64/** Paths go to curl inside double quotes: none of the characters that would end them. */
65const isSafePath = (p: string) => p.startsWith('/') && !/["\\\n\r;]/.test(p)
66
67/** A Drop from Claude's op, or why not. */
68export function dropFrom(raw: Raw): Drop | string {
69 const kind = (KINDS as readonly string[]).includes(str(raw.kind)) ? (str(raw.kind) as Kind) : raw.key !== undefined && raw.progress !== undefined ? 'progress' : 'done'
70 const title = str(raw.title)
71 const text = str(raw.text)
72 if (!title && !text) return `discord-drop: a drop needs a title or a text, e.g.\n${EXAMPLE}`
73 const fields: Field[] = []
74 if (raw.fields !== undefined && !Array.isArray(raw.fields)) return 'discord-drop: fields is a list of {"name","value","inline"}.'
75 for (const f of (raw.fields as unknown[] | undefined) ?? []) {
76 if (!f || typeof f !== 'object') continue
77 const o = f as Raw
78 const name = str(o.name)
79 const value = str(o.value)
80 if (!name && !value) continue
81 fields.push({ name: name || '', value: value || '', inline: o.inline !== false })
82 }
83 const image = str(raw.image) || null
84 const files = Array.isArray(raw.files) ? raw.files.map(str).filter(Boolean) : typeof raw.files === 'string' ? [str(raw.files)] : []
85 for (const p of image ? [image, ...files] : files) {
86 if (!isSafePath(p)) return `discord-drop: ${p} must be an absolute path without quotes, backslashes or semicolons.`
87 }
88 if (files.length + (image ? 1 : 0) > MAX_FILES) return `discord-drop: at most ${MAX_FILES} files per card (the image counts).`
89 const url = str(raw.url)
90 if (url && !/^https?:\/\/\S+$/.test(url)) return `discord-drop: url must be an http(s) link, got ${cut(url, 80)}.`
91 const key = str(raw.key).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || null
92 let progress = num(raw.progress)
93 if (progress !== null) progress = Math.max(0, Math.min(1, progress > 1 ? progress / 100 : progress))
94 let eta = num(raw.eta)
95 if (eta !== null && eta > 1e12) eta = Math.round(eta / 1000)
96 if (kind === 'progress' && !key) return 'discord-drop: a progress card needs a key, so later updates edit it.'
97 return {
98 kind,
99 title,
100 text,
101 fields,
102 image,
103 files,
104 url: url || null,
105 key,
106 progress,
107 eta,
108 final: raw.final === true,
109 force: raw.force === true,
110 author: str(raw.author) || null,
111 footer: str(raw.footer) || null,
112 }
113}
114
115/** Away: no message from the user to this session for `awayMinutes`, or none yet. */
116export function isAway(lastUserAt: number | null, now: number, awayMinutes: number): boolean {
117 return lastUserAt === null || now - lastUserAt >= awayMinutes * 60_000
118}
119
120/** The delivery rule. Present: nothing goes unless asked. Away: everything pings, progress quietly. */
121export function routeFor(kind: Kind, force: boolean, away: boolean): Route {
122 if (force) return kind === 'progress' ? 'silent' : 'ping'
123 if (!away) return 'hold'
124 return kind === 'progress' ? 'silent' : 'ping'
125}
126
127/** ~/.claude/discord-drop/presence.json: every session's last message from the user, read by jobs on other nodes. */
128export type Presence = { sessions: Record<string, { lastUserAt: number; host: string }> }
129
130const WEEK = 7 * 24 * 3600_000
131
132export function presenceWith(file: unknown, session: string, at: number, host: string): Presence {
133 const prev = file && typeof file === 'object' && (file as Presence).sessions && typeof (file as Presence).sessions === 'object' ? (file as Presence).sessions : {}
134 const sessions: Presence['sessions'] = {}
135 for (const [id, s] of Object.entries(prev)) {
136 if (s && typeof s.lastUserAt === 'number' && at - s.lastUserAt < WEEK) sessions[id] = { lastUserAt: s.lastUserAt, host: String(s.host ?? '') }
137 }
138 sessions[session] = { lastUserAt: at, host }
139 return { sessions }
140}
141
142/** The newest message from the user in any session, or null. */
143export function latestUserAt(file: unknown): number | null {
144 const sessions = file && typeof file === 'object' ? (file as Presence).sessions : null
145 if (!sessions || typeof sessions !== 'object') return null
146 const times = Object.values(sessions).map(s => s?.lastUserAt).filter((t): t is number => typeof t === 'number')
147 return times.length ? Math.max(...times) : null
148}
149
150/** "45 s", "12 min", "2 h 05 min", "3 d 4 h". */
151export function fmtDur(seconds: number): string {
152 const s = Math.max(0, Math.round(seconds))
153 if (s < 60) return `${s} s`
154 const m = Math.floor(s / 60)
155 if (m < 60) return `${m} min`
156 const h = Math.floor(m / 60)
157 if (h < 48) return `${h} h ${String(m % 60).padStart(2, '0')} min`
158 return `${Math.floor(h / 24)} d ${h % 24} h`
159}
160
161/**
162 * The next monthly /scratch purge (the first Tuesday of the month), as a
163 * local calendar date, on or after today's. `offsetMinutes` is the zone's
164 * offset from UTC (-240 in Boston's summer).
165 */
166export function nextPurge(now: number, offsetMinutes: number): { year: number; month: number; day: number; daysLeft: number } {
167 const local = new Date(now + offsetMinutes * 60_000)
168 const y = local.getUTCFullYear()
169 const m = local.getUTCMonth()
170 const today = Date.UTC(y, m, local.getUTCDate())
171 const firstTuesday = (year: number, month: number) => {
172 const dow = new Date(Date.UTC(year, month, 1)).getUTCDay()
173 return Date.UTC(year, month, 1 + ((2 - dow + 7) % 7))
174 }
175 let t = firstTuesday(y, m)
176 if (t < today) t = firstTuesday(m === 11 ? y + 1 : y, (m + 1) % 12)
177 const d = new Date(t)
178 return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), daysLeft: Math.round((t - today) / 86_400_000) }
179}
180hooks/embed.ts 203 lines1// The card: a Discord embed with one colour and icon per kind, and the curl
2// calls that post or edit it. Pure: a Drop in, the payload and argv out.
3// The webhook URL never enters an argv: curl reads it from stdin (`-K -`).
4
5import { cut, fmtDur } from './drop'
6import type { Drop, Field, Kind } from './drop'
7
8export const COLOR: Record<Kind, number> = {
9 result: 0x2ecc71,
10 urgent: 0xe74c3c,
11 'needs-you': 0xf1c40f,
12 progress: 0x3498db,
13 started: 0x9b59b6,
14 done: 0x5865f2,
15}
16
17export const ICON: Record<Kind, string> = {
18 result: '✅',
19 urgent: '🛑',
20 'needs-you': '⏸️',
21 progress: '⏳',
22 started: '▶️',
23 done: '✔️',
24}
25
26/** Discord's limits for one embed. */
27const LIMIT = { title: 256, description: 4096, fields: 25, name: 256, value: 1024, footer: 2048, author: 256, total: 6000 }
28/** SUPPRESS_NOTIFICATIONS: the message lands without a push or a sound. */
29export const SILENT = 1 << 12
30
31export type Lang = 'es' | 'en'
32export const STRINGS = {
33 es: {
34 finished: 'He terminado',
35 question: 'Tengo una pregunta',
36 took: 'Duración',
37 since: 'Desde tu último mensaje',
38 needsPermission: 'Necesito tu permiso',
39 needsAnswer: 'Necesito una respuesta',
40 planReady: 'Plan listo para revisar',
41 answerInTerminal: 'Responde en la terminal.',
42 waiting: 'Esperando',
43 sessionEnds: (m: string) => `Mi sesión se cierra en ${m}`,
44 sessionEndsText: 'El dev-shell llega a su límite de tiempo. Lo último que hice:',
45 purgeTitle: (d: string) => `Purga de /scratch el martes ${d}`,
46 purgeText: 'La política de Research Computing borra /scratch el primer martes de cada mes. Mueve a /projects lo que no puedas volver a descargar.',
47 test: 'Prueba de discord-drop',
48 testText: 'Si ves esta tarjeta, el webhook funciona.',
49 eta: 'ETA',
50 full: 'Texto completo adjunto.',
51 },
52 en: {
53 finished: 'Finished',
54 question: 'I have a question',
55 took: 'Took',
56 since: 'Since your last message',
57 needsPermission: 'I need your permission',
58 needsAnswer: 'I need an answer',
59 planReady: 'Plan ready for review',
60 answerInTerminal: 'Answer in the terminal.',
61 waiting: 'Waiting',
62 sessionEnds: (m: string) => `My session ends in ${m}`,
63 sessionEndsText: 'The dev-shell reaches its time limit. The last thing I did:',
64 purgeTitle: (d: string) => `/scratch purge on Tuesday ${d}`,
65 purgeText: "Research Computing's policy purges /scratch on the first Tuesday of each month. Move to /projects what you can't download again.",
66 test: 'discord-drop test',
67 testText: 'If you see this card, the webhook works.',
68 eta: 'ETA',
69 full: 'Full text attached.',
70 },
71} as const
72export type Strings = (typeof STRINGS)[Lang]
73
74export type Attachment = { path: string; name: string }
75
76/** Upload names: the basename, safe for `attachment://`, unique within the card. */
77export function attachments(drop: Drop, extra: readonly string[] = []): { image: Attachment | null; files: Attachment[] } {
78 const used = new Set<string>()
79 const name = (path: string) => {
80 const base = (path.split('/').pop() || 'file').replace(/[^A-Za-z0-9._-]+/g, '_').slice(-80) || 'file'
81 let n = base
82 for (let i = 2; used.has(n); i++) n = `${i}_${base}`
83 used.add(n)
84 return n
85 }
86 const image = drop.image ? { path: drop.image, name: name(drop.image) } : null
87 return { image, files: [...drop.files, ...extra].map(p => ({ path: p, name: name(p) })) }
88}
89
90/** "▰▰▰▰▰▱▱▱▱▱ 52%" */
91export function progressBar(p: number, width = 12): string {
92 const n = Math.round(Math.max(0, Math.min(1, p)) * width)
93 return `${'▰'.repeat(n)}${'▱'.repeat(width - n)} ${Math.round(p * 100)}%`
94}
95
96export type CardContext = {
97 host: string
98 project: string | null
99 now: number
100 lang: Lang
101 image: Attachment | null
102}
103
104export type Embed = {
105 color: number
106 title: string
107 url?: string
108 description?: string
109 fields?: { name: string; value: string; inline: boolean }[]
110 author?: { name: string }
111 footer: { text: string }
112 timestamp: string
113 image?: { url: string }
114}
115
116/**
117 * The embed for a drop, and whether its text had to be cut (the caller then
118 * attaches it in full). The total stays under Discord's 6000 characters.
119 */
120export function card(drop: Drop, ctx: CardContext): { embed: Embed; isCut: boolean } {
121 const s = STRINGS[ctx.lang]
122 const head: string[] = []
123 if (drop.kind === 'progress' && drop.progress !== null) head.push(progressBar(drop.final ? 1 : drop.progress))
124 if (drop.eta !== null && !drop.final) head.push(`${s.eta} <t:${Math.round(drop.eta)}:R>`)
125 const title = cut(`${ICON[drop.kind]} ${drop.title || cut(drop.text.split('\n')[0] ?? '', 80)}`, LIMIT.title)
126 const author = cut(drop.author ?? (ctx.project ? `Claude · ${ctx.project}` : 'Claude'), LIMIT.author)
127 const footer = cut(drop.footer ? `${drop.footer} · ${ctx.host}` : `discord-drop · ${ctx.host}`, LIMIT.footer)
128 const fields: Field[] = drop.fields.slice(0, LIMIT.fields).map(f => ({ name: cut(f.name, LIMIT.name), value: cut(f.value, LIMIT.value), inline: f.inline }))
129 const fixed = title.length + author.length + footer.length + fields.reduce((n, f) => n + f.name.length + f.value.length, 0)
130 const room = Math.max(200, Math.min(LIMIT.description, LIMIT.total - fixed - 50))
131 const body = [head.join(' · '), drop.title ? drop.text : drop.text.split('\n').slice(1).join('\n')].filter(Boolean).join('\n\n')
132 const isCut = body.length > room
133 const description = isCut ? `${cut(body, room - s.full.length - 6)}\n-# ${s.full}` : body
134 const embed: Embed = {
135 color: COLOR[drop.kind],
136 title,
137 ...(drop.url ? { url: drop.url } : {}),
138 ...(description ? { description } : {}),
139 ...(fields.length ? { fields } : {}),
140 author: { name: author },
141 footer: { text: footer },
142 timestamp: new Date(ctx.now).toISOString(),
143 ...(ctx.image ? { image: { url: `attachment://${ctx.image.name}` } } : {}),
144 }
145 return { embed, isCut }
146}
147
148/** The JSON a post carries: the card, its uploads, no pings for @-mentions in the text. */
149export function payload(embed: Embed, uploads: readonly Attachment[], isSilent: boolean): string {
150 return JSON.stringify({
151 embeds: [embed],
152 allowed_mentions: { parse: [] },
153 ...(isSilent ? { flags: SILENT } : {}),
154 ...(uploads.length ? { attachments: uploads.map((u, i) => ({ id: i, filename: u.name })) } : {}),
155 })
156}
157
158/** Webhook base: the URL without a query or trailing slash. */
159export const baseOf = (url: string) => url.trim().replace(/[?#].*$/, '').replace(/\/+$/, '')
160
161/** What `curl -K -` reads on stdin: just the URL. */
162export const curlConfig = (url: string) => `url = "${url.replace(/["\\]/g, '')}"\n`
163
164/** curl for a new message: multipart, the card in payload_json, each upload as files[i]. */
165export function postArgv(json: string, uploads: readonly Attachment[]): string[] {
166 return [
167 'curl', '-sS', '--max-time', '60', '-K', '-', '-w', '\n%{http_code}',
168 '--form-string', `payload_json=${json}`,
169 ...uploads.flatMap((u, i) => ['-F', `files[${i}]=@"${u.path}";filename="${u.name}"`]),
170 ]
171}
172
173/** curl for an edit of a message the webhook sent: the card as JSON, no uploads. */
174export function patchArgv(json: string): string[] {
175 return ['curl', '-sS', '--max-time', '30', '-K', '-', '-w', '\n%{http_code}', '-X', 'PATCH', '-H', 'Content-Type: application/json', '--data-raw', json]
176}
177
178export const postUrl = (base: string) => `${base}?wait=true`
179export const messageUrl = (base: string, id: string) => `${base}/messages/${id}`
180
181/** curl's stdout (body, then the status on its own line) as status and parsed body. */
182export function parseCurl(stdout: string): { status: number; body: Record<string, unknown> | null } {
183 const i = stdout.lastIndexOf('\n')
184 const status = Number(stdout.slice(i + 1).trim()) || 0
185 const text = stdout.slice(0, Math.max(0, i)).trim()
186 try {
187 const body = JSON.parse(text) as unknown
188 return { status, body: body && typeof body === 'object' ? (body as Record<string, unknown>) : null }
189 } catch {
190 return { status, body: null }
191 }
192}
193
194/** A short, URL-free reason for a failed call. */
195export function failure(status: number, body: Record<string, unknown> | null, stderr: string): string {
196 const msg = typeof body?.message === 'string' ? body.message : ''
197 const why = status ? `HTTP ${status}${msg ? `: ${msg}` : ''}` : stderr.replace(/https?:\/\/\S+/g, '<url>').trim().slice(0, 200) || 'no answer'
198 return `discord-drop: Discord refused the card (${why}).`
199}
200
201/** The duration field Claude's own cards carry. */
202export const tookField = (s: Strings, seconds: number): Field => ({ name: s.took, value: fmtDur(seconds), inline: true })
203