SLOPSHOPPER

turn-email

One email per completed main turn that the user started, per run end with nothing in flight, and per dialog that waits for the user

newguardpromptprocess
v1.0.0CC0-1.0updated 2026-10-08eturkes/agents/claude/mods/turn-email
A shopper browsing a rack in a slop shop
README

agents

Source 2 files
hooks/register.ts 160 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { RESPONSE_MAX, SEND, WAITING, WAKES, compose, mailable, questionsText, typedPrompt } from './mail'
4import type { Mail, Question } from './mail'
5
6const KEEP_MS = 7 * 24 * 3_600_000
7
8// Main loop facts, refreshed as the turn runs; Stop + Notification payloads add mode, cwd + transcript.
9let model = '?'
10let effort = '?'
11let mode = '?'
12let cwd = ''
13let transcript = '?'
14let mainOutput = 0
15let asking: readonly Question[] | undefined
16// The running main turn: its prompts' origins + whether its Stop saw work in flight (teammates, background shells, wakeups).
17let origins = new Set<string>()
18let idle = true
19
20/** Per session across resumes + reloads: completed-turn count and the last human prompt. */
21type Held = { n: number; at: number; prompt?: string }
22
23async function loadTurn($: EngineInterface, session: string): Promise<Held> {
24  const v = (await $.store.get(`turn:${session}`)) as { n?: unknown; prompt?: unknown } | undefined
25  const prompt = typeof v?.prompt === 'string' ? v.prompt : await lastHuman($)
26  return { n: typeof v?.n === 'number' ? v.n : 0, at: await $.clock.now(), prompt }
27}
28
29/** Bootstrap for a session the store has not seen (resumed from before the mod): its last human row. */
30async function lastHuman($: EngineInterface): Promise<string | undefined> {
31  const rows = await $.session.messages()
32  for (let i = rows.length - 1; i >= 0; i--) {
33    const r = rows[i]!
34    const text = r.role === 'user' && !r.toolResults?.length ? typedPrompt(r.text) : ''
35    if (text) return text
36  }
37  return undefined
38}
39
40function textOf(content: unknown): string {
41  if (typeof content === 'string') return content
42  return Array.isArray(content) ? content.map(b => (b?.type === 'text' && typeof b.text === 'string' ? b.text : '')).join('\n') : ''
43}
44
45function note(e: { permission_mode?: string; cwd: string; transcript_path: string; effort?: { level: string } }): void {
46  mode = e.permission_mode ?? mode
47  cwd = e.cwd || cwd
48  transcript = e.transcript_path || transcript
49  if (typeof e.effort?.level === 'string') effort = e.effort.level
50}
51
52async function prune($: EngineInterface): Promise<void> {
53  const now = await $.clock.now()
54  for (const key of await $.store.keys()) {
55    const held = (await $.store.get(key)) as { at?: unknown } | undefined
56    if (key.startsWith('turn:') && (typeof held?.at !== 'number' || now - held.at > KEEP_MS)) await $.store.delete(key)
57  }
58}
59
60async function send($: EngineInterface, session: string, turn: number, prompt: string, text: string, waiting?: Mail['waiting']): Promise<void> {
61  const { context } = await $.session.usage()
62  const to = (await $.env.get('CLAUDE_TURN_EMAIL_TO')) || 'emir.turkes@eturkes.com'
63  const mail = compose({
64    to,
65    from: (await $.env.get('CLAUDE_TURN_EMAIL_FROM')) || to,
66    session,
67    turn,
68    cwd: cwd || (await $.session.cwd()),
69    model,
70    effort,
71    mode,
72    context: context.tokens === undefined ? '?' : `${Math.floor((context.tokens + mainOutput) / 1000)}K`,
73    transcript,
74    now: await $.clock.now(),
75    prompt,
76    text,
77    waiting,
78  })
79  await $.process.run(['sh', '-c', SEND, 'sh', `session ${session} turn ${turn}`], { stdin: mail, timeoutMs: 30_000 }).catch(() => {})
80}
81
82export const register: Register = on => {
83  on('session.start', async ($, e, next) => {
84    await prune($)
85    return next(e)
86  })
87
88  // A human row = the turn's prompt or one folded into it; tool results, reminders + subagent rows excluded.
89  on('session.append', async ($, e, next) => {
90    const m = e.message
91    if (e.agentId === undefined && m.type === 'user' && m.isMeta !== true && (e.door === 'prompt' || e.door === 'delivery') && !WAKES.has(e.origin.kind)) {
92      const prompt = typedPrompt(textOf(m.content))
93      if (prompt) {
94        const session = await $.session.id()
95        await $.store.set(`turn:${session}`, { ...(await loadTurn($, session)), prompt })
96      }
97    }
98    return next(e)
99  })
100
101  on('prompt.submit', (_$, e, next) => {
102    origins.add(e.origin.kind)
103    return next(e)
104  })
105
106  on('turn.step', async function* (_$, e, next) {
107    if (e.agentId !== undefined) return yield* next(e)
108    model = e.model
109    if (typeof e.effort === 'string') effort = e.effort
110    const r = yield* next(e)
111    if (r.usage) mainOutput = r.usage.output_tokens
112    return r
113  })
114
115  on('classic.Stop', (_$, e, next) => {
116    if (e.agent_id === undefined) {
117      note(e)
118      idle = !e.background_tasks?.length && !e.session_crons?.length
119    }
120    return next(e)
121  })
122
123  // turn.complete fires once per turn, after any Stop block resolved; interrupts + API errors send nothing.
124  on('turn.complete', async ($, e, next) => {
125    const r = await next(e)
126    const text = e.answer.slice(0, RESPONSE_MAX)
127    if (e.agentId !== undefined) return r
128    const mail = mailable(origins, idle)
129    origins = new Set()
130    idle = true
131    if (!mail || (e.reason !== 'answer' && e.reason !== 'refusal')) return r
132    const session = await $.session.id()
133    const h = await loadTurn($, session)
134    if (!(h.prompt || text)) return r
135    await $.store.set(`turn:${session}`, { ...h, n: h.n + 1 })
136    await send($, session, h.n + 1, h.prompt ?? '', text)
137    return r
138  })
139
140  on('tool.call', { tool: 'AskUserQuestion' }, async (_$, e, next) => {
141    if (e.agentId !== undefined) return next(e)
142    asking = e.questions
143    try {
144      return await next(e)
145    } finally {
146      asking = undefined
147    }
148  })
149
150  on('classic.Notification', async ($, e, next) => {
151    const r = await next(e)
152    if (!WAITING.has(e.notification_type)) return r
153    note(e)
154    const waiting = { type: e.notification_type, message: e.message, questions: asking ? questionsText(asking) : '' }
155    const h = await loadTurn($, e.session_id)
156    await send($, e.session_id, h.n, h.prompt ?? '', '', waiting)
157    return r
158  })
159}
160
hooks/mail.ts 131 lines
1export const PROMPT_MAX = 4_000
2export const RESPONSE_MAX = 100_000
3/** Dialogs that hold a turn: AskUserQuestion + permission prompts emit no Stop ⇒ the mail is the signal. */
4export const WAITING = new Set(['permission_prompt', 'elicitation_dialog', 'agent_needs_input'])
5/** `prompt.submit` origins that wake the session without you. */
6export const WAKES = new Set([
7  'task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay', 'channel',
8  'coordinator', 'observer', 'observer-activity', 'unclassified', 'plugin',
9])
10
11/** Typed prompt → mail; `claude -p`/SDK → never; wakes alone or no prompt → only once nothing is in flight. */
12export function mailable(origins: ReadonlySet<string>, idle: boolean): boolean {
13  if ([...origins].some(k => k !== 'sdk' && !WAKES.has(k))) return true
14  return !origins.has('sdk') && idle
15}
16
17// Transcript rows that read as user text yet carry no typed prompt: compaction summaries + wake deliveries.
18const UNTYPED = /^(This session is being continued from a previous conversation|<task-notification>|Another Claude session sent a message|<(teammate|agent)-message[\s>])/
19
20// Spooled + backgrounded with its pipes closed: the run returns at once and the relay outlives
21// a -p process exit. Missing client/config ⇒ silent no-op; failures land in the cache log.
22export const SEND = `command -v msmtp >/dev/null 2>&1 && [ -r "$HOME/.msmtprc" ] || exit 0
23log=$HOME/.claude/cache/turn-email.log
24mkdir -p "\${log%/*}"
25mail=$(mktemp "\${TMPDIR:-/tmp}/cc-mail.XXXXXX") || exit 0
26cat >"$mail"
27(msmtp --read-recipients <"$mail" 2>>"$log" ||
28  printf '%s turn-email: send failed (%s)\\n' "$(date -Is)" "$1" >>"$log"
29 rm -f "$mail") </dev/null >/dev/null 2>&1 &`
30
31export type Question = { header?: string; question?: string; options?: readonly { label?: string; description?: string }[] }
32
33export function cleanPrompt(text: string): string {
34  return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim().slice(0, PROMPT_MAX)
35}
36
37export function typedPrompt(text: string): string {
38  const clean = cleanPrompt(text)
39  return UNTYPED.test(clean) ? '' : clean
40}
41
42export function questionsText(qs: readonly Question[]): string {
43  return qs.map(q => [`[${q.header}] ${q.question}`, ...(q.options ?? []).map(o => `  - ${o.label}: ${o.description}`)].join('\n')).join('\n')
44}
45
46const firstLine = (s: string) => s.split('\n')[0]!.slice(0, 72)
47
48const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
49export function base64(text: string): string {
50  const b = new TextEncoder().encode(text)
51  let out = ''
52  for (let i = 0; i < b.length; i += 3) {
53    const n = (b[i]! << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0)
54    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + (i + 1 < b.length ? B64[(n >> 6) & 63]! : '=') + (i + 2 < b.length ? B64[n & 63]! : '=')
55  }
56  return out
57}
58
59/** Local time: 2026-10-05 18:30:00 +0900 */
60export function stamp(ms: number): string {
61  const d = new Date(ms)
62  const p = (n: number) => String(n).padStart(2, '0')
63  const off = -d.getTimezoneOffset()
64  const zone = `${off < 0 ? '-' : '+'}${p(Math.trunc(Math.abs(off) / 60))}${p(Math.abs(off) % 60)}`
65  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${zone}`
66}
67
68export type Mail = {
69  to: string
70  from: string
71  session: string
72  turn: number
73  cwd: string
74  model: string
75  effort: string
76  mode: string
77  context: string
78  transcript: string
79  now: number
80  prompt: string
81  /** A completed turn's response, or the waiting dialog's text. */
82  text: string
83  waiting?: { type: string; message: string; questions: string }
84}
85
86/** RFC 5322 message: base64 text/plain body + RFC 2047 subject; References threads a session's turns. */
87export function compose(m: Mail): string {
88  const proj = m.cwd.replace(/\/+$/, '').split('/').at(-1) || 'home'
89  const w = m.waiting
90  const subject = w
91    ? `[cc] ${proj} #${m.turn} needs input — ${firstLine(w.questions || w.message) || '(dialog)'}`
92    : `[cc] ${proj} #${m.turn} — ${firstLine(m.prompt) || '(no prompt)'}`
93  const id = w ? `cc.${m.session}.${m.turn}.ask.${Math.floor(m.now / 1000)}` : `cc.${m.session}.${m.turn}`
94  const label = w ? 'input' : 'response'
95  const text = w ? `pending: ${w.type} — ${w.message}${w.questions ? `\n\n${w.questions}` : ''}` : m.text
96  const body = [
97    `Session   : ${m.session} (turn ${m.turn})`,
98    `Directory : ${m.cwd || '?'}`,
99    `Model     : ${m.model} · effort ${m.effort} · ${m.mode}`,
100    `Context   : ${m.context}`,
101    `Time      : ${stamp(m.now)}`,
102    `Transcript: ${m.transcript}`,
103    '',
104    '───────────────────────────── prompt ─────────────────────────────',
105    '',
106    m.prompt || '(none)',
107    '',
108    `──────────────────────────── ${label} ────────────────────────────`,
109    '',
110    text || '(none)',
111    '',
112  ].join('\n')
113  return [
114    `From: ${m.from}`,
115    `To: ${m.to}`,
116    `Subject: =?UTF-8?B?${base64(subject)}?=`,
117    `Date: ${new Date(m.now).toUTCString()}`,
118    `Message-ID: <${id}@eturkes.com>`,
119    `References: <cc.${m.session}@eturkes.com>`,
120    `In-Reply-To: <cc.${m.session}@eturkes.com>`,
121    'Auto-Submitted: auto-generated',
122    `X-Claude-Session: ${m.session}`,
123    'MIME-Version: 1.0',
124    'Content-Type: text/plain; charset=utf-8',
125    'Content-Transfer-Encoding: base64',
126    '',
127    ...(base64(body).match(/.{1,76}/g) ?? []),
128    '',
129  ].join('\n')
130}
131