SLOPSHOPPER

discord-drop

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…

newguardcommandtoastpromptprocess
v0.1.0no licenseupdated 2026-10-15didac-alonso/didac-mods/plugins/discord-drop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · discord-drop
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /drop ⎿ discord-drop: discord-drop ⎿ discord-drop: · webhook: missing: set DISCORD_WEBHOOK_URL ⎿ discord-drop: · you: here: your last message was 0 s ago ⎿ discord-drop: · cards reach Discord after 30 min away; a question or permission prompt after 5 min ⎿ discord-drop: · not inside a Slurm allocation ⎿ discord-drop: · pace-line: not watching jobs in this session (no alerts to forward) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

discord-drop

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.

  • Away, or asked. A card goes out only when you haven't written to the session for 30 minutes, or when you asked Claude to message you. Prompts from task notifications, schedules or other sessions don't count as you.
  • Cards. Each message is an embed with one colour and icon per kind: ✅ result (green), 🛑 urgent (red), ⏸️ needs you (amber), ⏳ progress (blue), ▶️ started (purple), ✔️ done. Numbers go in fields, the main plot sits inside the card, other files (≤ 10, ≤ 10 MB each) are attached under it.
  • Claude sends them. Claude writes : 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.
  • Progress cards. A card with a 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.
  • Safety net. When a long stretch of work ends while you're away and Claude sent nothing, a short done card goes out, at most once per message of yours.
  • Waiting on you. A permission prompt, a question or a plan to approve left 5 minutes goes to Discord with the command or the options.
  • Hand-off. Inside a Slurm allocation, 30 minutes before it ends Claude is asked to send where it left off; 15 minutes before, the mod sends it itself if Claude didn't.
  • Batch jobs. pace-line's alerts (finished, failed, OOM, NaN, idle GPU, near the limit, started after a long queue…) become cards, a finished job's with its loss chart. A job that reported itself with jobkit.notify() isn't repeated.
  • Scratch purge. Three days before the monthly /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.

Setup

Create a webhook (channel → Edit Channel → Integrations → Webhooks), then either:

  • export it before starting Claude Code: export DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/…, or
  • put the URL alone in ~/.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.

Files in ~/.claude/discord-drop/

FileWhat
webhookthe URL, mode 600
config.jsonthe away threshold, for jobs
presence.jsoneach 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

Settings

/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).

Source 5 files
hooks/register.ts 596 lines
1import 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}
596
hooks/bridge.ts 148 lines
1// 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}
148
hooks/chart.ts 195 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
195
hooks/drop.ts 180 lines
1// 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}
180
hooks/embed.ts 203 lines
1// 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