Shows a Claude Code-style session recap above the prompt after the session has been idle, or on resume.

A Claude Code mod that brings the terminal's session recap to the Claude desktop app's Code tab.
In the terminal, Claude Code drafts a one-line recap when you come back to a session after a few minutes away. The desktop app does not show it. This mod draws the same kind of recap above the prompt:
※ recap: You're fixing a failing build after an upstream rebase. Next: re-run the build.
In a terminal Claude Code session:
/plugin install desktop-recap --marketplace CrazyCoder/claude-desktop-recap
Answer y to add the marketplace and pick the user scope. Sessions the desktop app starts load it from then on.
awayMinutes (default 3) following Claude's last reply, in a session with at least three turns. The desktop app gives a mod no window-focus signal, so idle time stands in for "away". Resuming a session, or the app reopening it, recaps at once when the session has already been idle that long./recap you run yourself counts as that recap, and the band does not repeat it.| Option | Default | What it does |
|---|---|---|
awayMinutes | 3 | Idle minutes after Claude's last reply before the recap shows (1-120). |
prompt | empty | What the model is asked when it drafts a recap. Empty uses the built-in prompt. |
Each is a row in the terminal's /config, or set them in ~/.claude/settings.json:
"pluginConfigs": {
"desktop-recap": {
"options": {
"awayMinutes": 10,
"prompt": "In one sentence: what am I working on, and what is the next step?"
}
}
}
The built-in prompt is DEFAULT_PROMPT in hooks/register.tsx. A kept recap is reused only while it was made with the current prompt, so a new prompt takes effect at the next recap.
claude plugin validate .
claude plugin test .
The recap prompt comes from pi-session-recap by Thomas Mustier (MIT).
MIT
hooks/register.tsx 190 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Recap } from '../types'
5
6/** What the store keeps per session: its last recap and last activity. */
7type SessionRecord = { recap?: Recap; activeAt?: number }
8
9const recap = atom({ plugin: 'desktop-recap', key: 'recap' } as const, null)
10const isDismissed = atom({ plugin: 'desktop-recap', key: 'isDismissed' } as const, false)
11
12// The TUI's own thresholds: three minutes away (the `awayMinutes` default),
13// at least three turns.
14const MIN_TURNS = 3
15
16// The prompt of @jetserge/pi-session-recap, which mimics Claude Code's recap;
17// the `prompt` option replaces it.
18const DEFAULT_PROMPT =
19 'The user stepped away and is coming back. Write exactly 1-3 short sentences. ' +
20 'Start by stating the high-level task — what they are building or debugging, not ' +
21 'implementation details. Next: the concrete next step. Skip status reports and commit recaps.'
22
23// Sessions whose record the store keeps; the least recently active go first,
24// so the store stays far below its 4 MiB cap.
25const MAX_SESSIONS = 200
26const SESSION_PREFIX = 'session:'
27
28let away: Timer | undefined
29let isGenerating = false
30let recapPrompt = DEFAULT_PROMPT
31
32function cancelAway() {
33 away?.cancel()
34 away = undefined
35}
36
37async function sessionKey($: EngineInterface) {
38 return `${SESSION_PREFIX}${await $.session.id()}`
39}
40
41async function loadRecord($: EngineInterface) {
42 return ((await $.store.get(await sessionKey($))) ?? {}) as SessionRecord
43}
44
45async function saveRecord($: EngineInterface, change: SessionRecord) {
46 const key = await sessionKey($)
47 const record = { ...(await loadRecord($)), ...change }
48 await $.store.set(key, record)
49}
50
51// Drops the least recently active sessions past MAX_SESSIONS.
52async function pruneRecords($: EngineInterface) {
53 const keys = (await $.store.keys()).filter(key => key.startsWith(SESSION_PREFIX))
54 if (keys.length <= MAX_SESSIONS) return
55 const aged: [string, number][] = []
56 for (const key of keys) {
57 aged.push([key, ((await $.store.get(key)) as SessionRecord | undefined)?.activeAt ?? 0])
58 }
59 aged.sort((a, b) => a[1] - b[1])
60 for (const [key] of aged.slice(0, keys.length - MAX_SESSIONS)) await $.store.delete(key)
61}
62
63async function show($: EngineInterface, next: Recap, isSeen: boolean) {
64 await update($, recap, () => next)
65 await update($, isDismissed, () => isSeen)
66 await saveRecord($, { recap: next })
67}
68
69async function transcriptLength($: EngineInterface) {
70 return (await $.session.messages()).length
71}
72
73function isCurrent(kept: Recap | null | undefined, at: number): kept is Recap {
74 return kept?.at === at && kept.prompt === recapPrompt
75}
76
77// Reuses the recap already made for this point of the transcript with this
78// prompt, else asks the model over the session's cached transcript, as the
79// TUI does.
80async function recapNow($: EngineInterface) {
81 if (isGenerating || (await $.session.turns()) < MIN_TURNS) return
82 const at = await transcriptLength($)
83 if (isCurrent(await read($, recap), at)) return
84
85 const stored = (await loadRecord($)).recap
86 if (isCurrent(stored, at)) {
87 await show($, stored, false)
88 return
89 }
90
91 isGenerating = true
92 try {
93 const prompt = recapPrompt
94 const reply = await $.model.fork({ prompt })
95 // A prompt sent while the fork ran makes its answer stale.
96 if (reply.isAnswered && reply.text.trim() && (await transcriptLength($)) === at) {
97 await show($, { text: reply.text.trim(), at, prompt }, false)
98 }
99 } finally {
100 isGenerating = false
101 }
102}
103
104function startAway($: EngineInterface, delayMs: number) {
105 cancelAway()
106 away = $.clock.after(delayMs, () => void recapNow($))
107}
108
109async function markActivity($: EngineInterface) {
110 await saveRecord($, { activeAt: await $.clock.now() })
111}
112
113// session.start fires on a resume, an app restart and a reload of this mod
114// alike: recap only once the last activity is `awayMs` old, else wait the
115// rest; with no activity on record, wait the whole delay.
116async function resumeAway($: EngineInterface, awayMs: number) {
117 const last = (await loadRecord($)).activeAt
118 const idle = last === undefined ? 0 : (await $.clock.now()) - last
119 if (idle >= awayMs) await recapNow($)
120 else startAway($, awayMs - idle)
121}
122
123async function keepManualRecap($: EngineInterface, text: string) {
124 cancelAway()
125 // Kept under the current prompt, so the band does not redraft it.
126 await show($, { text, at: await transcriptLength($), prompt: recapPrompt }, true)
127}
128
129async function startSession($: EngineInterface, awayMs: number) {
130 await pruneRecords($)
131 await resumeAway($, awayMs)
132}
133
134async function dismiss($: EngineInterface) {
135 await update($, isDismissed, () => true)
136}
137
138export const register: Register = (on, options) => {
139 const awayMs = Number(options.awayMinutes) * 60 * 1000
140 recapPrompt = String(options.prompt ?? '').trim() || DEFAULT_PROMPT
141
142 on('session.start', async ($, e, next) => {
143 const result = await next(e)
144 void startSession($, awayMs)
145 return result
146 })
147
148 on('prompt.submit', async ($, e, next) => {
149 cancelAway()
150 await dismiss($)
151 await markActivity($)
152 return next(e)
153 }).catch(($, e, next) => next(e))
154
155 on('turn.complete', async ($, e, next) => {
156 if (e.agentId === undefined) {
157 await markActivity($)
158 startAway($, awayMs)
159 }
160 return next(e)
161 })
162
163 // A /recap the person ran counts as this point's recap: it is already in
164 // the transcript, so it is kept for reuse but not drawn again.
165 on('command.run', { command: 'recap' }, async ($, e, next) => {
166 const result = await next(e)
167 const text = result.text?.trim()
168 if (text) await keepManualRecap($, text)
169 return result
170 }).catch(($, e, next) => next(e))
171
172 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
173 const current = await read($, recap)
174 if (e.props.hasSurvey || e.props.isWorking || current === null || (await read($, isDismissed))) {
175 return next(e)
176 }
177
178 const { Box, Button, Text } = $.ui.resolve(e)
179
180 return (
181 <Box flexDirection="row" gap={1}>
182 <Box flexGrow={1}>
183 <Text>※ recap: {current.text}</Text>
184 </Box>
185 <Button key="dismiss" role="dismiss" label="Dismiss" onPress={() => dismiss($)} />
186 </Box>
187 )
188 })
189}
190types/index.d.ts 9 lines1/** A recap, the transcript length it summarizes and the prompt that asked for it. */
2export type Recap = { text: string; at: number; prompt: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'desktop-recap': { recap: Recap | null; isDismissed: boolean }
7 }
8}
9