SLOPSHOPPER

composio-daily

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

newpaneguardcommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · composio-daily
│ ┃ Standup ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ OK ⏺ Read(src/auth.ts) │ ┃ [ Copy ][ Regenerate ] ⎿ 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 │ │ › /standup │ ⎿ composio-daily: Standup pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Standup
OK [ Copy ][ Regenerate ]
README

claude-composio-mods

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

  • ticket-band: Shows the Linear ticket for the current branch above the prompt, with one-press Done once every PR has merged.
  • review-chaser: /reviews lists your open PRs waiting on reviewers, with a Slack nudge button per reviewer.
  • snippet: /snippet drafts your daily Slack update from the last 24h of Linear + GitHub and posts it on one press.
  • composio-daily: /standup pane, looks up failing-test errors in Datadog ("seen before"), and sets your Slack status while the agent is busy.
  • link-prefetch: Paste a Slack thread, Linear issue or GitHub PR link and Claude gets its contents as context before it answers.
  • composio-pulse: Times every composio CLI call into a status-line sparkline (/pulse for details) and blocks re-linking a toolkit that's already connected.
  • review-lint: When an edit adds a cache or a brittle test assertion, hands Claude a cache-review checklist or rules against implementation-coupled tests.

Install

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

Requirements

  • The Composio CLI, installed at ~/.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.

Settings

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.

Source 2 files
hooks/register.tsx 258 lines
1import { 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}
258
types/index.d.ts 8 lines
1export 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