Standup pane, seen-this-before error lookup and agent-busy Slack status, through the Composio CLI

Seven Claude Code mods (function-hook plugins) that wire your terminal into Slack, Linear, GitHub and Datadog through the Composio CLI.
Demo: video/out.mp4. The video is built with fframes; its source is in video/.
/reviews lists your open PRs waiting on reviewers, with a Slack nudge button per reviewer./snippet drafts your daily Slack update from the last 24h of Linear + GitHub and posts it on one press./standup pane, looks up failing-test errors in Datadog ("seen before"), and sets your Slack status while the agent is busy.composio CLI call into a status-line sparkline (/pulse for details) and blocks re-linking a toolkit that's already connected./plugin marketplace add tridha643/claude-composio-mods
/plugin install ticket-band@claude-composio-mods
Install whichever mods you want the same way. To hack on them instead, clone the repo and run claude --plugin-dir plugins/<mod>.
~/.composio/composio and logged in, with the toolkits each mod uses connected (Slack, Linear, GitHub, Datadog).gh, logged in, for review-chaser and snippet.review-chaser and snippet search PRs in the ComposioHQ org; change the query in hooks/register.tsx for your own org.Set these from the /plugin config menu:
composio-daily → standup_channel: the Slack channel the standup Post button sends to.snippet → channel: the Slack channel /snippet posts to.ticket-band → team_keys: comma-separated Linear team keys to look for in branch names. Empty matches any KEY-123.composio-pulse → search_budget_ms: marks the median search time ✓ or ✗ against this budget. 0 hides the mark.An empty channel hides that Post button.
hooks/register.tsx 258 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Standup } from '../types'
5
6type $ = EngineInterface
7type Json = any // composio responses are per-tool; each caller picks the fields it needs
8
9const PANE = 'standup'
10const STANDUP_MODEL = 'claude-haiku-4-5-20251001'
11const HOUR = 3_600_000
12
13const standup = atom({ plugin: 'composio-daily', key: 'standup' } as const, null)
14
15async function composio($: $, slug: string, data: unknown): Promise<Json | null> {
16 const bin = `${await $.env.get('HOME')}/.composio/composio`
17 const run = await $.process
18 .run([bin, 'execute', slug, '-d', JSON.stringify(data)], { timeoutMs: 20_000 })
19 .catch(() => null)
20 if (!run || run.exitCode !== 0) return null
21 const out = await Promise.resolve()
22 .then(() => JSON.parse(run.stdout))
23 .catch(() => null)
24 return out?.successful ? out.data : null
25}
26
27const searchPRs = async ($: $, q: string): Promise<Json[]> =>
28 (await composio($, 'GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS', { q, per_page: 30 }))?.items ?? []
29
30const linear = async ($: $, query: string, variables: object): Promise<Json | null> =>
31 (await composio($, 'LINEAR_RUN_QUERY_OR_MUTATION', { query_or_mutation: query, variables }))?.data ?? null
32
33// ---------------------------------------------------------------- standup
34
35const isoDate = (ms: number) => new Date(ms).toISOString().slice(0, 10)
36
37async function buildStandup($: $, now: number) {
38 const day = new Date(now).getDay()
39 const since = isoDate(now - (day === 1 ? 3 : day === 0 ? 2 : 1) * 24 * HOUR) // Monday looks back to Friday
40 const email = (await $.process.run(['git', 'config', 'user.email']).catch(() => null))?.stdout.trim()
41
42 const [commits, merged, open, tickets] = await Promise.all([
43 email
44 ? $.process
45 .run(['git', 'log', '--all', '--no-merges', `--author=${email}`, `--since=${since}`, '--pretty=%h %s'])
46 .then(r => r.stdout.trim())
47 .catch(() => '')
48 : '',
49 searchPRs($, `is:pr author:@me is:merged merged:>=${since}`),
50 searchPRs($, 'is:pr author:@me is:open archived:false'),
51 linear(
52 $,
53 'query($s: DateTimeOrDuration!){ viewer { assignedIssues(first: 30, filter:{updatedAt:{gte:$s}}){ nodes { identifier title url state{name} } } } }',
54 { s: since },
55 ),
56 ])
57
58 const pr = (p: Json) => `- #${p.number} ${p.title} (${p.html_url})${p.draft ? ' [draft]' : ''}`
59 const facts = [
60 `Commits since ${since}:\n${commits || '(none)'}`,
61 `PRs merged:\n${merged.map(pr).join('\n') || '(none)'}`,
62 `PRs open:\n${open.map(pr).join('\n') || '(none)'}`,
63 `Linear tickets touched:\n${
64 tickets?.viewer?.assignedIssues?.nodes
65 ?.map((t: Json) => `- ${t.identifier} ${t.title} [${t.state?.name}] (${t.url})`)
66 .join('\n') || '(none)'
67 }`,
68 ].join('\n\n')
69
70 return $.model.complete({
71 model: STANDUP_MODEL,
72 maxTokens: 800,
73 system:
74 'You write a software engineer\'s daily async standup for Slack. Three sections, bold headers: **Yesterday**, **Today**, **Blockers**. ' +
75 'Short bullets in plain words, grouped by theme not by commit. Link PRs and tickets as markdown links, e.g. [#123](url). ' +
76 'Today is a best guess from open PRs and in-progress tickets. Blockers is "None" unless the facts show one. No preamble, no sign-off.',
77 prompt: facts,
78 })
79}
80
81async function generate($: $) {
82 const now = await $.clock.now()
83 await update($, standup, s => ({ date: isoDate(now), text: s?.text ?? '', status: 'Gathering commits, PRs and tickets…' }))
84 const reply = await buildStandup($, now)
85 const next: Standup = reply.isAnswered
86 ? { date: isoDate(now), text: reply.text, status: '' }
87 : { date: isoDate(now), text: '', status: `Draft failed: ${reply.reason}` }
88 await update($, standup, () => next)
89 if (reply.isAnswered) await $.store.set('standup.last', next)
90}
91
92// ---------------------------------------------------------------- seen this before
93
94const ago = (ms: number) => {
95 const d = ms / (24 * HOUR)
96 return d >= 14 ? `${Math.round(d / 7)}w ago` : d >= 1 ? `${Math.round(d)}d ago` : `${Math.round(ms / HOUR)}h ago`
97}
98
99// Picks the line that names the failure and strips what differs between runs (paths, numbers, ids).
100function errorSignature(output: string): string | null {
101 const lines = output
102 .replace(/\x1b\[[0-9;]*m/g, '')
103 .split('\n')
104 .map(l => l.trim())
105 .filter(l => l.length > 12)
106 // an error message names the cause better than a FAIL banner, so it wins when both are there
107 const line = lines.find(l => /(error|exception|panic|fatal)\b/i.test(l)) ?? lines.find(l => /\bfail/i.test(l))
108 if (!line) return null
109 const sig = line
110 .replace(/\S*\/\S*/g, ' ')
111 .replace(/\b[0-9a-f]{8,}\b|[\d.:]*\d[\d.:]*/gi, ' ')
112 .replace(/["`]|(?<=\s)[^\w\s]+(?=\s|$)/g, ' ')
113 .replace(/\s+/g, ' ')
114 .trim()
115 return sig.length > 12 ? sig.slice(0, 100) : null
116}
117
118async function seenBefore($: $, sig: string): Promise<string[]> {
119 const now = await $.clock.now()
120 const [slack, tickets, logs] = await Promise.all([
121 composio($, 'SLACK_SEARCH_MESSAGES', { query: `"${sig}"`, count: 1, sort: 'timestamp' }),
122 composio($, 'LINEAR_SEARCH_ISSUES', { query: sig, first: 1 }),
123 composio($, 'CUSTOM_DATADOG_MCP_SEARCH_DATADOG_LOGS', {
124 query: `"${sig}"`,
125 from: 'now-30d',
126 limit: 1,
127 max_tokens: 2000,
128 telemetry: { intent: 'Look up whether a local command failure was seen before in production logs' },
129 }),
130 ])
131 const hits: string[] = []
132 const msg = slack?.messages?.matches?.[0]
133 if (msg) hits.push(`Slack #${msg.channel?.name ?? '?'} ${ago(now - Number(msg.ts) * 1000)} ${msg.permalink ?? ''}`.trim())
134 const issue = tickets?.issues?.[0] ?? tickets?.nodes?.[0]
135 if (issue) hits.push(`Linear ${issue.identifier} [${issue.state?.name ?? issue.state ?? '?'}] ${issue.title}`)
136 // Datadog answers a TSV report, not JSON: read the match count and the explorer link out of it
137 const report = String(logs?.data ?? '')
138 const count = Number(report.match(/<count>(\d+)<\/count>/)?.[1] ?? 0)
139 if (count > 0) hits.push(`Datadog ${count} logs in 30d ${report.match(/<logs_explorer_url>(.*?)<\/logs_explorer_url>/)?.[1] ?? ''}`.trim())
140 return hits
141}
142
143// ---------------------------------------------------------------- agent-busy status
144
145const BUSY_AFTER = 3 * 60_000
146
147type Busy = { turnId: string; timer: { cancel: () => void }; isSet: boolean }
148
149async function setBusy($: $, busy: Busy) {
150 const me = (await composio($, 'SLACK_TEST_AUTH', {}))?.user_id
151 const profile = me && (await composio($, 'SLACK_RETRIEVE_USER_PROFILE_INFORMATION', { user: me }))?.profile
152 if (profile?.status_text) return // a status you set yourself wins
153 const expiresAt = Math.round((await $.clock.now()) / 1000) + 3600 // self-clears if the session dies mid-run
154 const ok = await composio($, 'SLACK_SET_STATUS', {
155 profile: JSON.stringify({ status_text: 'agent cooking 🤖', status_emoji: ':robot_face:', status_expiration: expiresAt }),
156 })
157 busy.isSet = ok !== null
158}
159
160async function clearBusy($: $) {
161 await composio($, 'SLACK_SET_STATUS', {
162 profile: JSON.stringify({ status_text: '', status_emoji: '', status_expiration: 0 }),
163 })
164}
165
166// ---------------------------------------------------------------- wiring
167
168export const register: Register = (on, options) => {
169 const channel = String(options.standup_channel ?? '').trim()
170 const seenSigs = new Set<string>()
171 let busy: Busy | null = null
172
173 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
174 const ran = await next(e)
175 if (ran.isError !== true) return ran
176 const sig = errorSignature(ran.text ?? JSON.stringify(ran.result ?? ''))
177 if (sig && !seenSigs.has(sig)) {
178 seenSigs.add(sig)
179 void seenBefore($, sig).then(hits => hits.length > 0 && $.ui.toast(`Seen before: ${hits.join(' · ')}`))
180 }
181 return ran
182 })
183
184 on('turn.start', ($, e, next) => {
185 if (!busy) {
186 const b: Busy = { turnId: e.turnId, timer: { cancel: () => {} }, isSet: false }
187 busy = b
188 b.timer = $.clock.after(BUSY_AFTER, () => void setBusy($, b))
189 }
190 return next(e)
191 })
192
193 on('turn.complete', ($, e, next) => {
194 const b = busy
195 if (b && e.turnId === b.turnId && !e.agentId) {
196 busy = null
197 b.timer.cancel()
198 if (b.isSet) void clearBusy($)
199 }
200 return next(e)
201 })
202
203 on('session.start', async ($, e, next) => {
204 await $.command.register({ name: 'standup', description: 'Open the standup pane (drafts one if today has none)' })
205
206 const last = (await $.store.get('standup.last')) as Standup | undefined
207 const today = isoDate(await $.clock.now())
208 if (last?.date === today) {
209 await update($, standup, () => last)
210 } else if (new Date().getHours() >= 6) {
211 void $.ui.open({ id: PANE, title: 'Standup' })
212 void generate($)
213 }
214 return next(e)
215 })
216
217 on('command.run', { command: 'standup' }, async $ => {
218 await $.ui.open({ id: PANE, title: 'Standup' })
219 if ((await read($, standup))?.date !== isoDate(await $.clock.now())) void generate($)
220 return { text: 'Standup pane opened.' }
221 })
222
223 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
224 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
225 const s = await read($, standup)
226
227 return (
228 <Box flexDirection="column">
229 {s?.status && <Text dimColor>{s.status}</Text>}
230 {s?.text ? <Markdown text={s.text} /> : !s?.status && <Text dimColor>No standup yet.</Text>}
231 <Box>
232 {s?.text && channel && (
233 <Button
234 key="post"
235 label={`Post to ${channel}`}
236 onPress={async () => {
237 const sent = await composio($, 'SLACK_SEND_MESSAGE', { channel, markdown_text: s.text })
238 $.ui.toast(sent ? `Standup posted to ${channel}` : 'Slack post failed')
239 }}
240 />
241 )}
242 {s?.text && (
243 <Button
244 key="copy"
245 label="Copy"
246 onPress={async pe => {
247 const r = await $.ui.copy({ text: s.text, surface: pe.surface })
248 $.ui.toast(r.isCopied ? 'Copied' : 'Copy failed')
249 }}
250 />
251 )}
252 <Button key="regen" label="Regenerate" onPress={() => void generate($)} />
253 </Box>
254 </Box>
255 )
256 })
257}
258types/index.d.ts 8 lines1export type Standup = { date: string; text: string; status: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'composio-daily': { standup: Standup | null }
6 }
7}
8