A second pair of eyes while Claude works. Every few model steps it asks one tool-less fork of the session (served from the prompt cache) whether there is…

A second pair of eyes while Claude works.
A Claude Code mod. Every few model steps of the main thread, lookout asks one question of a tool-less fork of the session: is there one thing the person running it would want to know now and could easily miss? The fork carries the main thread's own transcript, system prompt and model, so the API serves almost all of it from the prompt cache. A yes shows above the prompt:
✦ lookout The deploy did finish, but deploy.log line 3 shows migration 0042_add_orders_index failed after a lock timeout and was rolled back, so the deploy went out without that index. ok
ok (or o once the band has focus) dismisses it; otherwise it stays for one more prompt and clears on the second. The question asks for things that would change what the person does: a failed, skipped or cut-short check the work carried on past, a guess stated as fact, a change beyond what was asked, work against an instruction they gave, a decision of theirs being made without them, time or cost well past what the task needs. It leaves out progress reports and anything the assistant already told them plainly. Most checks answer none and show nothing.
Claude Code 2.1.292 ships a built-in plugin with the same idea, cc-plugin-you-should-know. It does not load on this host: the engine enables it only once it has settled the account's compliance status (HIPAA, zero or limited data retention), and it treats a session behind a gateway (ANTHROPIC_BASE_URL not api.anthropic.com) or with an x-api-key custom header as unsettled, so the plugin stays off. In the 2.1.292 bundle the gate is bn, which needs R0n(), that is verdict.complianceTaintsSettled(); the check behind it, pur(), returns false on nx() (an x-api-key custom header) and on !xg() (an ANTHROPIC_BASE_URL that is not first-party), among other conditions. Its code is Anthropic's and not licensed for copying; lookout is written from scratch against the public mod API and copies none of it. It also leaves out what the built-in has for Anthropic: answer buttons that send telemetry, and a page it publishes to explain a card.
Each check is one $.model.fork: a cache read of the whole conversation, a few hundred uncached tokens, and about 70 to 350 output tokens at the session's own model and effort. Measured on this host through the gateway (2026-10-06, Opus 5.5, a session of about 69,000 tokens): 69,294 to 69,463 tokens read from the cache, 356 to 476 uncached and 73 to 202 output per warm check; the one fork timed took 11 seconds. The first check of each of the three measured sessions read 37,532 tokens from the cache and paid about 32,000 uncached.
No check is sent while nothing draws the session ($.session.surfaces() is empty: a claude -p run, a background job no client is attached to), so the fleet's unattended work costs nothing. A check that comes due while the last is still out is skipped. /lookout reports how many checks this session sent and what they read and wrote.
/lookout on or off, the last check, what the checks cost, the flags so far
/lookout now check at once
/lookout off stop checking, in this session and later ones
/lookout on start again
| Option | Default | |
|---|---|---|
every | 6 | Main-thread model steps between checks. 0 turns the automatic checks off; /lookout now still asks. |
Set it in settings.json under pluginConfigs.lookout.options.
claude plugin validate reports./register.tsx hooks: session.start, session.end, turn.step, prompt.submit, command.run{command=lookout}, ui.render{component=AbovePrompt}
./register.tsx calls: $.command.register, $.model.fork (via check), $.session.surfaces (via isWatched), $.state.get, $.state.set, $.store.delete, $.store.get, $.store.set, $.ui.resolve
turn.step with no agentId). The fork reads the main thread's transcript, so counting a subagent's steps would only ask about the same conversation more often.Lookout:, or none. Anything else, a failed fork (api-error, empty-reply, aborted) and nothing-to-fork (before the first response, after /clear) show nothing. /lookout names the last outcome./lookout off also stops a check already under way: one that has not sent its fork yet sends none, and one that has shows no flag, even if lookout is back on when the reply comes./clear or a resume goes on in the same process under another conversation, with no new session.start, so lookout starts that one over at session.end: the flags, the step count, the totals and the flag on screen go, and a check still out sends, counts and shows nothing after it, and does not hold back the new conversation's checks. The on/off switch carries over. Compaction keeps the same conversation, so it changes none of this.$.store at session start counts as off, since each check reads the whole transcript; /lookout says why, and /lookout on turns it on. When /lookout off or on cannot save the switch, it still holds in this session, and the reply and /lookout say a later session may start the other way./lookout now says a check is out). A /clear or a resume starts a conversation that checks again.AbovePrompt band, a flag is kept but not shown; /lookout lists it./clear or a resume. The flag on screen survives a hot reload ($.state); the off switch survives all three ($.store).claude plugin validate ./lookout
claude plugin test ./lookout
tsc -p ./lookout # after the engine has loaded it once
MIT.
hooks/register.tsx 347 lines1// lookout: a second pair of eyes while Claude works. Every few main-thread model steps, one
2// tool-less fork of the session's own transcript (served from the main thread's prompt cache) is
3// asked whether there is something the person should know and might miss; the answer shows above
4// the prompt until it is dismissed or two prompts have gone by. No model call while nothing draws
5// the session, so a background job nobody is attached to costs nothing.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9const DEFAULT_EVERY = 6
10const MAX_EVERY = 1000
11const CLEAR_AFTER_PROMPTS = 2
12const MAX_FLAG = 280
13const MAX_FLAGS_KEPT = 20
14const FLAGS_IN_QUESTION = 5
15const OFF_KEY = 'off'
16const NONE = /^none\.?$/i
17
18// Held by the host, so a flag on screen survives a hot reload of this file.
19const card = atom({ plugin: 'lookout', key: 'card' } as const, null as string | null)
20
21const USAGE = [
22 '/lookout on or off, the last check, what the checks cost, the flags so far',
23 '/lookout now check at once',
24 '/lookout off stop checking, in this session and later ones',
25 '/lookout on start again',
26].join('\n')
27
28// What one load of the module keeps between hooks; a hot reload starts it over.
29type Watch = Conversation & {
30 every: number
31 isOff: boolean
32 // The conversation whose check is out, or null. Not reset with the rest at a /clear or a resume:
33 // the next conversation is free to check because the number has moved on, and a check from the
34 // ended one that settles later clears only its own mark, never a newer check's.
35 checking: number | null
36 // Moves on each /lookout off, so a check begun before it neither forks nor shows its flag,
37 // even when lookout is back on by the time the check gets that far.
38 generation: number
39 // Moves when a conversation ends, so a check begun in it sends, counts and shows nothing after.
40 conversation: number
41 // Why the on/off switch in `$.store` may not match this session, after a read or save of it
42 // failed; null once a save goes through.
43 unsaved: string | null
44}
45
46// What lookout has seen of one conversation; the next one in the process starts from `fresh()`.
47type Conversation = {
48 mainSteps: number
49 promptsSinceFlag: number
50 flags: string[]
51 last: string
52 spent: { checks: number; input: number; cacheRead: number; cacheWrite: number; output: number }
53}
54
55function fresh(): Conversation {
56 return {
57 mainSteps: 0,
58 promptsSinceFlag: 0,
59 flags: [],
60 last: 'none yet',
61 spent: { checks: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
62 }
63}
64
65export const register: Register = (on, options) => {
66 const w: Watch = {
67 every: everyOf(options.every),
68 isOff: false,
69 checking: null,
70 generation: 0,
71 conversation: 0,
72 unsaved: null,
73 ...fresh(),
74 }
75
76 on('session.start', async ($, e, next) => {
77 const started = await next(e)
78 // A switch that cannot be read counts as off: each fork reads the whole transcript, so lookout
79 // sends none the person may have turned off.
80 w.isOff = await $.store.get(OFF_KEY).then(
81 value => value === true,
82 error => {
83 w.unsaved = `could not be read (${reason(error)}), so lookout started off`
84 return true
85 },
86 )
87 await $.command
88 .register({ name: 'lookout', description: 'Lookout status; /lookout now checks at once, /lookout off or on' })
89 .catch(() => {}) // a name Claude Code already has is refused: start anyway
90 return started
91 })
92
93 // A /clear or a resume goes on in this process under another conversation, and no session.start
94 // fires for it: what lookout saw of this one ends here, a check still out included. Only the
95 // on/off switch carries over. Should clearing the flag on screen fail, the end still goes through.
96 on('session.end', async ($, e, next) => {
97 Object.assign(w, fresh())
98 w.conversation++
99 await update($, card, () => null).catch(() => {})
100 return next(e)
101 })
102
103 // Count the main thread's model steps; the fork reads the main thread's transcript, so a
104 // subagent's steps would only make it ask about the same conversation more often.
105 on('turn.step', async function* ($, e, next) {
106 const result = yield* next(e)
107 if (!e.agentId && w.every > 0 && !w.isOff && ++w.mainSteps % w.every === 0) void check($, w)
108 return result
109 })
110
111 // A flag stays for one more prompt and clears on the second. Should clearing it fail, the
112 // prompt still goes through untouched.
113 on('prompt.submit', async ($, e, next) => {
114 if ((await read($, card)) !== null && ++w.promptsSinceFlag >= CLEAR_AFTER_PROMPTS) {
115 await update($, card, () => null)
116 }
117 return next(e)
118 }).catch(($, e, next) => next(e))
119
120 on('command.run', { command: 'lookout' }, async ($, e) => {
121 const arg = e.args.trim().toLowerCase()
122 if (arg === '') return { text: status(w) }
123 if (arg === 'now') {
124 if (w.isOff) return { text: 'lookout is off, so it sent no check. /lookout on turns it back on.' }
125 if (w.checking === w.conversation) return { text: 'lookout already has a check out; its flag, if there is one, shows above the prompt.' }
126 void check($, w)
127 return { text: 'lookout is checking now; a flag, if there is one, shows above the prompt.' }
128 }
129 // Each switch holds in this session whatever the store does; only the reply about later
130 // sessions depends on the save.
131 if (arg === 'off') {
132 w.isOff = true
133 w.generation++
134 const unsaved = await save(w, $.store.set(OFF_KEY, true), 'a later session may start with it on')
135 await update($, card, () => null)
136 return {
137 text: unsaved
138 ? `lookout is off in this session, but the setting ${unsaved}. /lookout on turns it back on.`
139 : 'lookout is off, in this session and later ones. /lookout on turns it back on.',
140 }
141 }
142 if (arg === 'on') {
143 w.isOff = false
144 const unsaved = await save(w, $.store.delete(OFF_KEY), 'a later session may still start with it off')
145 return {
146 text: unsaved
147 ? `lookout is on in this session: ${cadence(w)}. The setting ${unsaved}.`
148 : `lookout is on: ${cadence(w)}.`,
149 }
150 }
151 return { text: USAGE }
152 })
153
154 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
155 const below = await next(e)
156 const flag = await read($, card)
157 if (e.props.hasSurvey || flag === null) return below
158
159 const { Box, Text, Button } = $.ui.resolve(e)
160 const rows = [
161 <Box key="lookout" flexDirection="row" columnGap={1} paddingX={1}>
162 <Text key="mark" color="magenta" bold>
163 ✦ lookout
164 </Text>
165 <Text key="flag" wrap="wrap">
166 {flag}
167 </Text>
168 <Button
169 key="dismiss"
170 label="ok"
171 hotkey="o"
172 plain
173 role="dismiss"
174 onPress={() => void update($, card, () => null)}
175 />
176 </Box>,
177 ]
178 if (below) rows.push(below)
179 return <Box flexDirection="column">{rows}</Box>
180 })
181}
182
183// One check at a time in a conversation: a step that comes due while a fork is still out is
184// skipped, not queued. A check whose conversation ended while it waited leaves the next one's
185// record untouched and does not hold back its checks.
186async function check($: EngineInterface, w: Watch) {
187 if (w.checking === w.conversation) return
188 const generation = w.generation
189 const conversation = w.conversation
190 w.checking = conversation
191 try {
192 if (!(await isWatched($)) || conversation !== w.conversation) return
193 if (generation !== w.generation) {
194 w.last = 'no check sent: lookout was turned off as it started'
195 return
196 }
197 const reply = await $.model.fork({ prompt: question(w.flags.slice(-FLAGS_IN_QUESTION)) })
198 if (conversation !== w.conversation) return
199 if ('usage' in reply) {
200 w.spent.checks++
201 w.spent.input += reply.usage.input_tokens ?? 0
202 w.spent.cacheRead += reply.usage.cache_read_input_tokens ?? 0
203 w.spent.cacheWrite += reply.usage.cache_creation_input_tokens ?? 0
204 w.spent.output += reply.usage.output_tokens ?? 0
205 }
206 if (!reply.isAnswered) {
207 w.last =
208 reply.reason === 'api-error' ? `api-error ${reply.status ?? 'no response'} (${reply.error})` : reply.reason
209 return
210 }
211 const flag = parse(reply.text)
212 if (flag === undefined) w.last = 'a reply not in the expected shape'
213 else if (flag === null) w.last = 'nothing to flag'
214 else if (w.flags.some(seen => sameFlag(seen, flag))) w.last = 'a flag already shown'
215 else if (generation !== w.generation) w.last = 'a flag not shown: lookout was turned off while the check was out'
216 else {
217 w.flags = [...w.flags, flag].slice(-MAX_FLAGS_KEPT)
218 w.last = 'flagged'
219 w.promptsSinceFlag = 0
220 // `update` runs this again when another write beat it, as a /clear or an off clearing the
221 // card does; by then the flag is no longer this check's to show.
222 await update($, card, shown => (conversation === w.conversation && generation === w.generation ? flag : shown))
223 }
224 } catch {
225 if (conversation === w.conversation) w.last = 'the check could not be sent'
226 } finally {
227 if (w.checking === conversation) w.checking = null
228 }
229}
230
231// Waits for a write of the on/off switch to `$.store`. `w.unsaved` is cleared when it goes
232// through and, when it fails, says why and what a `later` session may do instead; that is also
233// what this returns.
234async function save(w: Watch, write: Promise<void>, later: string): Promise<string | null> {
235 w.unsaved = await write.then(
236 () => null,
237 error => `could not be saved (${reason(error)}), so ${later}`,
238 )
239 return w.unsaved
240}
241
242function reason(error: unknown): string {
243 return error instanceof Error ? error.message : String(error)
244}
245
246function cadence(w: Watch): string {
247 return w.every > 0
248 ? `a check every ${w.every} model steps while something draws the session`
249 : 'automatic checks off (every = 0); /lookout now checks once'
250}
251
252function status(w: Watch): string {
253 const lines = [
254 w.isOff ? 'lookout: off (/lookout on turns it back on)' : `lookout: on, ${cadence(w)}`,
255 ...(w.unsaved ? [`on/off setting: ${w.unsaved}`] : []),
256 `last check: ${w.last}`,
257 `checks this session: ${w.spent.checks} (prompt cache read ${thousands(w.spent.cacheRead)} tokens, ` +
258 `cache write ${thousands(w.spent.cacheWrite)}, uncached input ${thousands(w.spent.input)}, ` +
259 `output ${thousands(w.spent.output)})`,
260 ]
261 if (w.flags.length) lines.push('flags this session:', ...w.flags.map(flag => `- ${flag}`))
262 return lines.join('\n')
263}
264
265// Whether anything draws this session: `$.session.surfaces()` is empty in a plain -p run and in a
266// background job no client is attached to. An engine with no answer for it counts as unwatched:
267// each fork reads the whole transcript, so lookout sends none it could not show.
268export async function isWatched($: EngineInterface): Promise<boolean> {
269 try {
270 return (await $.session.surfaces()).length > 0
271 } catch {
272 return false
273 }
274}
275
276// The one user message each fork answers. The fork carries the main thread's system prompt and
277// transcript, so this says first that it is not a step of the task.
278export function question(alreadyFlagged: readonly string[]): string {
279 return [
280 'This message comes from lookout, a plugin watching this session for the person running it. ' +
281 'It is not from them and not a step of the task, and your reply goes to a note above their ' +
282 'prompt, not into the conversation. Do not continue the task, do not call tools, and do not ' +
283 'answer as the assistant.',
284 '',
285 'Read the conversation so far as a careful second pair of eyes. Is there one thing the person ' +
286 'would want to know now and could easily miss? Only something that would change what they do, ' +
287 'such as:',
288 '- a command, test or check that failed, was skipped or was cut short while the work carried on as if it had passed',
289 '- a guess or assumption stated as fact',
290 '- a change beyond what was asked: other files, settings, branches, pushes, deletions, money spent or messages sent',
291 '- work that goes against an instruction the person gave',
292 '- a decision that is theirs to make being made without them',
293 '- time or cost running well past what the task needs',
294 '',
295 'Leave out progress reports, anything the assistant already told the person plainly, and matters of style.',
296 ...(alreadyFlagged.length
297 ? ['Already flagged, so leave these out too:', ...alreadyFlagged.map(flag => `- ${flag}`)]
298 : []),
299 '',
300 'Answer with exactly one line: `none`, or `Lookout: ` followed by what they should know, ' +
301 'concrete and under 30 words.',
302 ].join('\n')
303}
304
305/**
306 * The flag in a fork's reply: the text after `Lookout:` on the first line that has it, `null` for
307 * `none` (alone or as the flag), `undefined` for a reply in neither shape, which is not shown.
308 */
309export function parse(text: string): string | null | undefined {
310 for (const raw of text.split(/\r?\n/)) {
311 const line = raw.trim().replace(/^[\s>*`"'-]+/, '').replace(/[\s`"']+$/, '')
312 const labelled = /^lookout\s*:(.*)$/i.exec(line)
313 if (labelled) {
314 const flag = clean(labelled[1] ?? '')
315 return flag === '' || NONE.test(flag) ? null : flag
316 }
317 if (NONE.test(line)) return null
318 }
319 return undefined
320}
321
322// Terminal escape sequences and control characters out, whitespace collapsed, cut to MAX_FLAG.
323function clean(text: string): string {
324 const plain = text
325 .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
326 .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
327 .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
328 .replace(/\s+/g, ' ')
329 .trim()
330 return plain.length > MAX_FLAG ? `${plain.slice(0, MAX_FLAG - 1).trimEnd()}…` : plain
331}
332
333// Two flags are the same when they differ only in case, spacing and punctuation.
334function sameFlag(a: string, b: string): boolean {
335 const key = (flag: string) => flag.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
336 return key(a) === key(b)
337}
338
339function everyOf(value: unknown): number {
340 const n = Math.floor(Number(value ?? DEFAULT_EVERY))
341 return Number.isFinite(n) && n >= 0 ? Math.min(n, MAX_EVERY) : DEFAULT_EVERY
342}
343
344function thousands(n: number): string {
345 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
346}
347types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 lookout: {
4 /** The flag shown above the prompt; null when there is none. */
5 card: string | null
6 }
7 }
8}
9