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

hooks/register.ts 160 lines1import 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}
160hooks/mail.ts 131 lines1export 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