SLOPSHOPPER

idle-dedupe

Shortens a subagent's idle_notification when that agent already delivered its report by SendMessage or an earlier idle result.

★ 1v0.1.0MITupdated 2026-10-18davethedev3/idle-dedupe
A shopper browsing a rack in a slop shop
README

idle-dedupe

A Claude Code mod that stops a subagent's completion from arriving twice. A subagent that reports to the main conversation by SendMessage is followed by the engine's idle_notification, whose result restates the same report; a late message can also wake a finished agent for one more turn and a second notice. This mod keeps such a repeat's name and idleReason and cuts its result to one line that begins with the first 120 characters of the final text.

A notice is cut only when all of these hold:

  • the agent sent a report to main, or already delivered an idle result, within the last 15 minutes;
  • the main conversation hasn't messaged the agent since then, so a reply to a follow-up question arrives whole;
  • its idleReason is absent or available, so errors are never cut.

Install

From the davesmods marketplace:

claude plugin marketplace add davethedev3/davesmods
claude plugin install idle-dedupe@davesmods

Function-hooks mods are an early-access Claude Code feature (written against 2.1.302). To run from a checkout instead, load the folder with claude --plugin-dir /path/to/idle-dedupe, or list it in CLAUDE_CODE_PLUGIN_DIRS (colon-separated) to load it in every session. The engine writes the plugin API's type declarations to .claude-plugin/types/ when it loads the mod; they are not part of this repo.

Checks: claude plugin validate . and claude plugin test . from this folder. The SendMessage path can't be raised from the test kit, so it was verified with a live subagent.

Source 1 files
hooks/register.ts 74 lines
1import type { Register } from 'claude-code'
2
3// A subagent's completion reaches the main conversation twice: once as the
4// report it sent by SendMessage, and again as the engine's idle_notification
5// carrying its final text, which restates the same report. A late inbox
6// message can also wake a finished agent for one more turn, which ends in a
7// second idle_notification. This hook keeps the name and idleReason of such a
8// repeat and cuts its result to one line.
9//
10// Measured 2026-10-04 on 2.1.289: a subagent's SendMessage to main raises
11// session.send with the sender's agentId and never reaches session.receive;
12// the idle_notification reaches session.receive as bare JSON text, the
13// <teammate-message> wrapper being added afterwards.
14
15const WINDOW_MS = 15 * 60 * 1000
16
17const reported = new Map<string, number>()   // agent name -> time it sent a report to main
18const idled = new Map<string, number>()      // agent name -> time its last idle result arrived
19const asked = new Map<string, number>()      // agent name -> time main last messaged it
20
21const isRecent = (at: number | undefined, now: number) => at !== undefined && now - at <= WINDOW_MS
22
23// A notice repeats an earlier delivery only when nothing was asked of the
24// agent since then; a reply to a follow-up SendMessage is a new answer.
25function isRepeat(from: string, now: number): boolean {
26  const last = Math.max(reported.get(from) ?? 0, idled.get(from) ?? 0)
27  if (!isRecent(last, now)) return false
28  const askedAt = asked.get(from)
29  return askedAt === undefined || askedAt < last
30}
31
32function parseIdle(text: string): Record<string, unknown> | undefined {
33  const body = text.trim()
34  if (!body.startsWith('{')) return undefined
35  try {
36    const value = JSON.parse(body)
37    return value && value.type === 'idle_notification' && typeof value.from === 'string' ? value : undefined
38  } catch {
39    return undefined
40  }
41}
42
43export const register: Register = on => {
44  on('session.send', async ($, e, next) => {
45    if (e.agentId !== undefined && e.to === 'main') {
46      const sender = (await $.agent.list()).find(a => a.id === e.agentId)
47      if (sender?.name !== undefined) reported.set(sender.name, await $.clock.now())
48    } else if (e.agentId === undefined) {
49      const now = await $.clock.now()
50      asked.set(e.to, now)
51      const target = (await $.agent.list()).find(a => a.id === e.to || a.teammateId === e.to)
52      if (target?.name !== undefined) asked.set(target.name, now)
53    }
54    return next(e)
55  })
56
57  on('session.receive', async ($, e, next) => {
58    const notice = parseIdle(e.text)
59    if (notice === undefined) return next(e)
60
61    const now = await $.clock.now()
62    const from = notice.from as string
63    const result = typeof notice.result === 'string' ? notice.result : undefined
64    const reason = notice.idleReason
65    const cut = (reason === undefined || reason === 'available') && isRepeat(from, now) && result !== undefined
66    idled.set(from, now)
67    if (!cut || result === undefined) return next(e)
68
69    const head = result.replace(/\s+/g, ' ').slice(0, 120)
70    notice.result = `[idle-dedupe] ${from} is idle; its report was already delivered above. Final text began: ${head}`
71    return next({ ...e, text: JSON.stringify(notice) })
72  })
73}
74