SLOPSHOPPER

Third eye

A footnote under Claude's reply with one idea you wouldn't have suggested yourself

newrowscommandtoastpromptmodel
v0.1.0Apache-2.0updated 2026-10-04hacksurvivor/claude-mods/mods/third-eye
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · third-eye
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /third-eye ⎿ third-eye: Your third eye came back without an idea. Try again in a bit. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Third eye

A Claude Code mod that shows you the idea you wouldn't have had. A little while after Claude answers, a folded footnote appears under the reply: Another angle ›. Open it and you get one idea from outside your usual frame, anchored in something from this chat, with the blind spot it comes from.

Third eye in the Claude desktop app: an idea unfolded under Claude's reply, with Ask Claude about this and Not for me, and the folded footnote below

  • Ask Claude about this sends the idea as your next message.
  • Not for me hides it, and the eye remembers what you turned down so the next ideas steer away from that kind.
  • /third-eye asks for an idea right now. /third-eye off rests it for the session, /third-eye on wakes it.

It looks at most once every 15 minutes, and only after you've been quiet for 25 seconds, so it never interrupts a back-and-forth.

Requirements

Claude Code 2.1.286 or later (mods). The footnote is drawn in the Claude desktop app and in the terminal.

Install

In Claude Code:

/plugin marketplace add hacksurvivor/claude-mods
/plugin install third-eye@hacksurvivor
/reload-plugins

What it runs, reads and sends

  • It asks your session's own model for the idea, as a fork of the current conversation, so Claude's prompt cache serves most of it. That request goes to Anthropic under your Claude account, like any other turn, and counts toward your usage.
  • It reads the conversation only through that fork, plus the last reply's text after a reload.
  • It keeps the ideas it showed and the ones you took or turned down in Claude Code's plugin store on your computer.
  • It runs no commands, opens no network connections of its own, and has no server or analytics.

See PRIVACY.md.

License

Apache-2.0

Source 3 files
hooks/register.tsx 215 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Idea } from '../types'
5import { IDLE_MS, anchorOf, isDue, isUnder, parseIdea, question, readTaste, remember } from './eye'
6
7// A footnote under Claude's reply with one idea the person wouldn't have
8// suggested themselves. The eye looks a little while after a reply, at most
9// once in 15 minutes, by asking the session's own model over this chat (a
10// fork, so the chat's prompt cache serves it). /third-eye asks it now.
11
12const TASTE_KEY = 'taste'
13const IDEAS_KEPT = 20
14
15const ideas = atom({ plugin: 'third-eye', key: 'ideas' } as const, [] as Idea[])
16const open = atom({ plugin: 'third-eye', key: 'open' } as const, [] as string[])
17const looking = atom({ plugin: 'third-eye', key: 'looking' } as const, null as string | null)
18const isPaused = atom({ plugin: 'third-eye', key: 'isPaused' } as const, false)
19
20let waiting: Timer | undefined
21let isBusy = false
22let lastAnchor: string | undefined
23
24// Resolves the new idea, or a sentence saying why there is none.
25async function look($: EngineInterface, anchor: string): Promise<Idea | string> {
26  if (isBusy) {
27    return 'Your third eye is already looking.'
28  }
29
30  isBusy = true
31  await update($, looking, () => anchor)
32
33  try {
34    const taste = readTaste(await $.store.get(TASTE_KEY))
35    const reply = await $.model.fork({ prompt: question(taste) })
36
37    if (!reply.isAnswered) {
38      return reply.reason === 'nothing-to-fork'
39        ? 'Nothing to look at yet. Ask Claude something first.'
40        : `Your third eye couldn't look this time (${reply.reason}).`
41    }
42
43    const found = parseIdea(reply.text)
44
45    if (found === undefined) {
46      return 'Your third eye came back without an idea. Try again in a bit.'
47    }
48
49    const at = await $.clock.now()
50    const idea: Idea = { id: `eye-${at}`, anchor, ...found, at, status: 'new' }
51    await update($, ideas, list => [...list, idea].slice(-IDEAS_KEPT))
52    await update($, open, list => [...list, idea.id])
53
54    return idea
55  } finally {
56    await update($, looking, () => null)
57    isBusy = false
58  }
59}
60
61// The reply the eye looks from: the last one this module saw end, or after a
62// reload, the chat's last assistant text.
63async function lastReply($: EngineInterface): Promise<string | undefined> {
64  if (lastAnchor !== undefined) {
65    return lastAnchor
66  }
67
68  const rows = await $.session.messages()
69  const row = [...rows].reverse().find(item => item.role === 'assistant' && item.text.trim() !== '')
70
71  return row === undefined ? undefined : anchorOf(row.text)
72}
73
74async function ask($: EngineInterface, idea: Idea): Promise<void> {
75  await update($, ideas, list => list.map(item => (item.id === idea.id ? { ...item, status: 'asked' as const } : item)))
76  await $.store.set(TASTE_KEY, remember(readTaste(await $.store.get(TASTE_KEY)), 'asked', idea.idea))
77  await $.prompt.submit({ text: idea.prompt, asUser: true })
78}
79
80async function dismiss($: EngineInterface, idea: Idea): Promise<void> {
81  await update($, ideas, list => list.map(item => (item.id === idea.id ? { ...item, status: 'dismissed' as const } : item)))
82  await $.store.set(TASTE_KEY, remember(readTaste(await $.store.get(TASTE_KEY)), 'dismissed', idea.idea))
83  $.ui.toast('Got it. Fewer ideas like that one.')
84}
85
86async function fold($: EngineInterface, id: string): Promise<void> {
87  await update($, open, list => (list.includes(id) ? list.filter(item => item !== id) : [...list, id]))
88}
89
90// The footnote's paragraph: the blind spot, then the idea, in the reply's own voice.
91function paragraph(idea: Idea): string {
92  const sentence = /[.!?]$/.test(idea.idea) ? idea.idea : `${idea.idea}.`
93
94  return `*${`${idea.why} ${sentence}`.replace(/\*/g, '\\*')}*`
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    await $.command.register({
100      name: 'third-eye',
101      description: "Ask your third eye for an idea you wouldn't suggest, or turn it off or on",
102      argumentHint: '[off|on]',
103    })
104
105    return next(e)
106  })
107
108  on('command.run', { command: 'third-eye' }, async ($, e) => {
109    const arg = e.args.trim().toLowerCase()
110
111    if (arg === 'off' || arg === 'on') {
112      await update($, isPaused, () => arg === 'off')
113      waiting?.cancel()
114
115      return {
116        text: arg === 'off' ? 'Your third eye is resting for this session. /third-eye on wakes it.' : 'Your third eye is awake.',
117      }
118    }
119
120    const anchor = await lastReply($)
121
122    if (anchor === undefined) {
123      return { text: 'Nothing to look at yet. Ask Claude something first.' }
124    }
125
126    waiting?.cancel()
127    const found = await look($, anchor)
128
129    return { text: typeof found === 'string' ? found : `Another angle: ${found.idea}` }
130  })
131
132  // A while after a main-loop reply, unless the person has written again.
133  on('turn.complete', async ($, e, next) => {
134    const done = await next(e)
135
136    if (e.agentId !== undefined || e.reason !== 'answer' || e.answer.trim() === '') {
137      return done
138    }
139
140    const anchor = anchorOf(e.answer)
141    lastAnchor = anchor
142
143    if (!(await read($, isPaused)) && isDue(await $.clock.now(), await read($, ideas))) {
144      waiting?.cancel()
145      waiting = $.clock.after(IDLE_MS, () => void look($, anchor))
146    }
147
148    return done
149  })
150
151  on('prompt.submit', ($, e, next) => {
152    waiting?.cancel()
153    waiting = undefined
154
155    return next(e)
156  })
157
158  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
159    const text = e.props.text
160    const mine = (await read($, ideas)).filter(idea => idea.status !== 'dismissed' && isUnder(idea.anchor, text))
161    const lookingAt = await read($, looking)
162    const isLookingHere = lookingAt !== null && isUnder(lookingAt, text)
163
164    if (mine.length === 0 && !isLookingHere) {
165      return next(e)
166    }
167
168    const drawn = await next(e)
169    const unfolded = await read($, open)
170    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
171
172    return (
173      <Box flexDirection="column">
174        {drawn}
175        {mine.map(idea => {
176          const isOpen = unfolded.includes(idea.id)
177
178          return (
179            <Box key={idea.id} flexDirection="column" marginTop={1} marginLeft={2}>
180              <Button
181                key={`fold-${idea.id}`}
182                label={isOpen ? 'Another angle ⌄' : 'Another angle ›'}
183                plain
184                dimColor
185                onPress={() => fold($, idea.id)}
186              />
187              {isOpen ? (
188                <Box key={`body-${idea.id}`} flexDirection="column" marginLeft={2}>
189                  <Markdown key={`text-${idea.id}`} text={paragraph(idea)} dimColor />
190                  {idea.status === 'new' ? (
191                    <Box key={`acts-${idea.id}`} flexDirection="row" columnGap={1}>
192                      <Button key={`ask-${idea.id}`} label="Ask Claude about this" plain onPress={() => ask($, idea)} />
193                      <Text dimColor>·</Text>
194                      <Button key={`nope-${idea.id}`} label="Not for me" plain dimColor onPress={() => dismiss($, idea)} />
195                    </Box>
196                  ) : (
197                    <Text key={`asked-${idea.id}`} dimColor>
198                      Asked
199                    </Text>
200                  )}
201                </Box>
202              ) : null}
203            </Box>
204          )
205        })}
206        {isLookingHere ? (
207          <Box key="looking" marginTop={1} marginLeft={2}>
208            <Text dimColor>Another angle…</Text>
209          </Box>
210        ) : null}
211      </Box>
212    )
213  })
214}
215
hooks/eye.ts 83 lines
1// What the third eye asks, how it reads the answer, and when it looks.
2
3import type { Idea, Taste } from '../types'
4
5// Quiet for this long after a reply before the eye looks: the person is reading.
6export const IDLE_MS = 25_000
7// At most one unasked-for idea in this window.
8export const GAP_MS = 15 * 60_000
9const ANCHOR_CHARS = 160
10const TASTE_KEPT = 30
11
12export function anchorOf(text: string): string {
13  return text.trim().slice(-ANCHOR_CHARS)
14}
15
16// The last text block of a reply ends where the turn's answer ends.
17export function isUnder(anchor: string, blockText: string): boolean {
18  const block = anchorOf(blockText)
19
20  return block.length >= 20 && anchor.endsWith(block.slice(-Math.min(block.length, 80)))
21}
22
23export function isDue(now: number, ideas: readonly Idea[]): boolean {
24  const last = ideas.at(-1)
25
26  return last === undefined || now - last.at >= GAP_MS
27}
28
29export function question(taste: Taste): string {
30  const avoid = taste.dismissed.slice(-12)
31  const liked = taste.asked.slice(-6)
32
33  return [
34    'Step outside this conversation for a moment and act as the person\'s third eye.',
35    'Look only at what the person (not you) has asked for in this session: the questions they keep asking, what they measure, what they take for granted, and what they never ask about.',
36    'Offer ONE idea they would not have suggested themselves: an angle outside their habitual frame that matters for what they are actually trying to achieve.',
37    'Not the obvious next step, not a follow-up on what you just did, and not generic advice (tests, docs, backups, monitoring) unless it is truly surprising here. It must be concrete enough to start on in this session.',
38    'Look from a side they skip: the person on the other end, the money, the time, the people outside the work, a different field that solved this already. Anchor it in one specific detail from this chat, so it could only have been said here.',
39    avoid.length > 0 ? `They turned these down before, so stay away from their kind: ${avoid.map(item => `"${item}"`).join('; ')}.` : '',
40    liked.length > 0 ? `They took these up before: ${liked.map(item => `"${item}"`).join('; ')}.` : '',
41    'Write in the language the person writes in. Plain words, no hype.',
42    'Reply with only this JSON, nothing around it:',
43    '{"idea": "<the idea as one imperative sentence, at most 14 words>", "why": "<one or two sentences in second person naming the blind spot>", "prompt": "<what they could send you to start on it, in their voice>"}',
44  ]
45    .filter(line => line !== '')
46    .join('\n')
47}
48
49export function parseIdea(reply: string): Pick<Idea, 'idea' | 'why' | 'prompt'> | undefined {
50  const start = reply.indexOf('{')
51  const end = reply.lastIndexOf('}')
52
53  if (start < 0 || end <= start) {
54    return undefined
55  }
56
57  try {
58    const data = JSON.parse(reply.slice(start, end + 1)) as Record<string, unknown>
59    const { idea, why, prompt } = data
60
61    if (typeof idea === 'string' && typeof why === 'string' && idea.trim() !== '' && why.trim() !== '') {
62      return { idea: idea.trim(), why: why.trim(), prompt: typeof prompt === 'string' && prompt.trim() !== '' ? prompt.trim() : idea.trim() }
63    }
64  } catch {
65    // not JSON
66  }
67
68  return undefined
69}
70
71export function remember(taste: Taste, kind: keyof Taste, idea: string): Taste {
72  return { ...taste, [kind]: [...taste[kind].filter(item => item !== idea), idea].slice(-TASTE_KEPT) }
73}
74
75export function readTaste(stored: unknown): Taste {
76  const value = (stored ?? {}) as Partial<Taste>
77
78  return {
79    dismissed: Array.isArray(value.dismissed) ? value.dismissed.filter(item => typeof item === 'string') : [],
80    asked: Array.isArray(value.asked) ? value.asked.filter(item => typeof item === 'string') : [],
81  }
82}
83
types/index.d.ts 28 lines
1// One idea the third eye offered, and the reply it sits under.
2export type Idea = {
3  id: string
4  // The end of the reply's text, to find the block it hangs under.
5  anchor: string
6  idea: string
7  why: string
8  prompt: string
9  at: number
10  status: 'new' | 'asked' | 'dismissed'
11}
12
13// What the person turned down or took, kept across sessions to steer the next ones.
14export type Taste = { dismissed: string[]; asked: string[] }
15
16declare module 'claude-code' {
17  interface PluginState {
18    'third-eye': {
19      ideas: Idea[]
20      // Ideas whose footnote is unfolded.
21      open: string[]
22      // The anchor of the reply the eye is looking at now, if any.
23      looking: string | null
24      isPaused: boolean
25    }
26  }
27}
28