SLOPSHOPPER

desktop-recap

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

newbandpromptmodeltimer
★ 1v0.2.0MITupdated 2026-10-12CrazyCoder/claude-desktop-recap
A shopper browsing a rack in a slop shop
README

desktop-recap

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.

Install

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.

How it works

  • When. After the session has been idle for 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.
  • How. One tool-less request over the session's own transcript, served from the prompt cache, the way the terminal makes its recap. Nothing is added to the transcript or to what the model reads.
  • Where. A line above the prompt with a Dismiss button. Your next prompt takes it down.
  • Reuse. A recap is kept for the point of the transcript it covers. With nothing new since, the next idle period, a reload or an app restart shows the kept recap and makes no new request. A /recap you run yourself counts as that recap, and the band does not repeat it.

Settings

OptionDefaultWhat it does
awayMinutes3Idle minutes after Claude's last reply before the recap shows (1-120).
promptemptyWhat 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.

Development

claude plugin validate .
claude plugin test .

Credits

The recap prompt comes from pi-session-recap by Thomas Mustier (MIT).

License

MIT

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