SLOPSHOPPER

duo-glance

Glanceable Claude Code on the iPhone Duo: a summary of what happened while you were away, and a desk monitor for the half-folded phone

paneguardcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · duo-glance
│ ┃ duo-away ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing happened while you were away. │ ⏺ 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 │ │ › /glance │ ⎿ duo-glance: Phone attached: no. Not counting an absence. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · duo-away
Nothing happened while you were away.
Pane · duo-monitor
Idle Context 49% Waiting for your next prompt Rate limit 31% Tests failing · 3 passed, 1 Cost so far $0.42 failed
README

duo-glance: see your Claude Code session at a glance on the iPhone Duo (v0.1)

It works alongside the duo mod, and you can also install it on its own.

Away summary

When the Claude app on your phone leaves the session (you closed it or put the phone away), the mod starts counting what happens. When the phone opens the session again, a While you were away pane shows:

  • how long you were gone, how many turns finished, which files changed and how many errors there were
  • the latest test run, green or red, with pass and fail counts
  • anything waiting on you, such as an approval or a question Claude asked

Its buttons are Review changes (Claude summarizes the changes file by file), Monitor and Got it. While you're away, a notification arrives whenever a turn finishes or something needs your OK. If nothing happened, no pane opens.

Desk monitor

Run /glance monitor, or tap Monitor, then prop the Duo half-open next to your laptop. The monitor shows:

  • a large timer for the running turn, and what Claude is doing right now
  • the last test run in green or red
  • context-window and rate-limit gauges (SVG bars on the phone, text on the terminal), and the cost so far

Folded, everything stacks in one column. Unfolded, it splits into status and gauges side by side.

Commands

/glance monitor · /glance away (starts counting now, useful for testing) · /glance status

Install

At the prompt of a terminal session:

/plugin install duo-glance --marketplace jayozer/iphone_duo_mods_plugins

To try it from a clone, run claude --plugin-dir claude-code/mods/duo-glance --remote-control and open the session from the Claude app on your phone. Run claude plugin test claude-code/mods/duo-glance for the tests (12 tests).

Known limits

  • The mod can't tell when the phone is half-folded, so you open the monitor yourself.
  • Test runs are recognized by their command (npm/pnpm/yarn/bun test, pytest, jest, vitest, go/cargo test and others), and the pass and fail counts are read from the output text.
Source 3 files
hooks/register.tsx 456 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Away, Gauges, TreeSnapshot } from '../types'
5import {
6  BREAKPOINT,
7  baseName,
8  callInput,
9  changedBetween,
10  clock,
11  describeCall,
12  duration,
13  emptyAway,
14  field,
15  firstQuestion,
16  gaugeSvg,
17  dirtyPaths,
18  hasNews,
19  isTestCommand,
20  outputText,
21  readTestRun,
22  reviewPrompt,
23  testLine,
24} from './lib'
25
26const AWAY_PANE = 'duo-away'
27const MONITOR_PANE = 'duo-monitor'
28const WRITE_TOOLS = ['Edit', 'Write', 'NotebookEdit']
29const NO_GAUGES: Gauges = { contextPercent: null, ratePercent: null, costUsd: null }
30
31const isPhone = atom({ plugin: 'duo-glance', key: 'isPhone' } as const, false)
32const away = atom({ plugin: 'duo-glance', key: 'away' } as const, null)
33const summary = atom({ plugin: 'duo-glance', key: 'summary' } as const, null)
34const pending = atom({ plugin: 'duo-glance', key: 'pending' } as const, [])
35const steps = atom({ plugin: 'duo-glance', key: 'steps' } as const, [])
36const turnStartedAt = atom({ plugin: 'duo-glance', key: 'turnStartedAt' } as const, null)
37const lastTest = atom({ plugin: 'duo-glance', key: 'lastTest' } as const, null)
38const gauges = atom({ plugin: 'duo-glance', key: 'gauges' } as const, NO_GAUGES)
39const isMonitorOpen = atom({ plugin: 'duo-glance', key: 'isMonitorOpen' } as const, false)
40const now = atom({ plugin: 'duo-glance', key: 'now' } as const, 0)
41
42const openMonitor = async ($: EngineInterface) => {
43  const usage = await $.session.usage()
44  await update($, gauges, () => readGauges(usage))
45  const at = await $.clock.now()
46  await update($, now, () => at)
47  await update($, isMonitorOpen, () => true)
48  await $.ui.open({ id: MONITOR_PANE, title: 'Monitor' })
49}
50
51function readGauges(usage: {
52  context: { percent?: number }
53  rateLimits: readonly { percentUsed: number }[]
54  cost?: { usd: number }
55}): Gauges {
56  return {
57    contextPercent: usage.context.percent ?? null,
58    ratePercent: usage.rateLimits.length === 0 ? null : Math.max(...usage.rateLimits.map(limit => limit.percentUsed)),
59    costUsd: usage.cost?.usd ?? null,
60  }
61}
62
63const git = async ($: EngineInterface, args: string[], cwd?: string) => {
64  const ran = await $.process.run(['git', ...args], { cwd, timeoutMs: 4000 })
65  return ran.exitCode === 0 ? ran.stdout : null
66}
67
68// The checkout now, or null outside a git checkout (or where commands can't run).
69const readTree = async ($: EngineInterface): Promise<TreeSnapshot | null> => {
70  try {
71    const top = await git($, ['rev-parse', '--show-toplevel'])
72    if (top === null) return null
73    const root = top.trim()
74    const status = await git($, ['status', '--porcelain', '-z', '--untracked-files=all'], root)
75    if (status === null) return null
76    const head = (await git($, ['rev-parse', 'HEAD'], root))?.trim() ?? null
77    const marks = await Promise.all(
78      dirtyPaths(status).map(async relative => {
79        const path = `${root}/${relative}`
80        try {
81          const stat = await $.fs.stat(path)
82          return [path, `${stat.size}:${stat.mtimeMs}`] as const
83        } catch {
84          return [path, 'gone'] as const
85        }
86      }),
87    )
88    return { root, head, marks: Object.fromEntries(marks) }
89  } catch {
90    return null
91  }
92}
93
94// Files that differ from the checkout as the phone left it: changed in the tree since,
95// or moved by a commit, checkout or reset (which can leave the tree clean).
96const changedSince = async ($: EngineInterface, then: TreeSnapshot): Promise<string[]> => {
97  const current = await readTree($)
98  if (current === null || current.root !== then.root) return []
99  const paths = changedBetween(then.marks, current.marks)
100  if (then.head !== null && current.head !== null && then.head !== current.head) {
101    const moved = await git($, ['diff', '--name-only', '-z', then.head, current.head], then.root).catch(() => null)
102    for (const relative of (moved ?? '').split('\0').filter(Boolean)) paths.push(`${then.root}/${relative}`)
103  }
104  return [...new Set(paths)]
105}
106
107// How many times the phone has left; a reconnect that sees this change mid-way leaves the
108// absence open.
109let departures = 0
110
111// The phone left: start counting what happens until it comes back.
112const leave = async ($: EngineInterface) => {
113  const id = ++departures
114  const since = await $.clock.now()
115  await update($, isPhone, () => false)
116  // Open the record before the checkout scan, so calls that finish during it still count.
117  const opened = { isNew: false }
118  await update($, away, current => {
119    if (current !== null) return current
120    opened.isNew = true
121    return emptyAway(since, id)
122  })
123  if (!opened.isNew) return
124  const tree = await readTree($)
125  if (tree !== null) await update($, away, held => (held?.id === id ? { ...held, tree } : held))
126}
127
128const notifyAway = async ($: EngineInterface, text: string) => {
129  if ((await read($, away)) === null) return
130  void $.ui.notify(text, { title: 'Claude Code' }).catch(() => undefined)
131}
132
133export const register: Register = on => {
134  let shellsRunning = 0
135
136  on('session.start', async ($, e, next) => {
137    await $.command.register({
138      name: 'glance',
139      description: 'iPhone Duo glance: /glance monitor, /glance away, or /glance status',
140      argumentHint: 'monitor | away | status',
141    })
142    const surfaces = await $.session.surfaces()
143    await update($, isPhone, () => surfaces.includes('mobile'))
144    const usage = await $.session.usage()
145    await update($, gauges, () => readGauges(usage))
146    $.clock.every(1000, () => {
147      void (async () => {
148        if ((await read($, isMonitorOpen)) && (await read($, turnStartedAt)) !== null) {
149          const at = await $.clock.now()
150          await update($, now, () => at)
151        }
152      })().catch(() => undefined)
153    })
154
155    return next(e)
156  })
157
158  on('session.attach', { surface: 'mobile' }, async ($, e, next) => {
159    const result = await next(e)
160    await update($, isPhone, () => true)
161    const seen = departures
162    const open = await read($, away)
163    // A shell command still running may already have written files: count them now,
164    // since once the absence ends its own check finds nothing to add to.
165    const touched = open?.tree && shellsRunning > 0 ? await changedSince($, open.tree) : []
166    // Close the record as it stands now, with whatever finished during the scan, unless
167    // the phone left again meanwhile: then the absence goes on.
168    const closed: { away: Away | null } = { away: null }
169    await update($, away, current => {
170      if (current === null || departures !== seen) return current
171      closed.away = { ...current, changed: [...new Set([...current.changed, ...touched])] }
172      return null
173    })
174    const held = closed.away
175    if (held !== null && (hasNews(held) || (await read($, pending)).length > 0)) {
176      const until = await $.clock.now()
177      await update($, summary, () => ({ ...held, until }))
178      await $.ui.open({ id: AWAY_PANE, title: 'While you were away' })
179    }
180
181    return result
182  })
183
184  on('session.detach', async ($, e, next) => {
185    const result = await next(e)
186    if (e.surface !== 'mobile' || e.reason === 'end') return result
187    const surfaces = await $.session.surfaces()
188    if (!surfaces.includes('mobile')) await leave($)
189
190    return result
191  })
192
193  on('command.run', { command: 'glance' }, async ($, e) => {
194    const word = e.args.trim().split(/\s+/)[0] || 'status'
195    if (word === 'monitor') {
196      await openMonitor($)
197      return { text: 'Desk monitor opened. Prop the phone half-open and it shows the session at a glance.' }
198    }
199    if (word === 'away') {
200      // Pretend the phone just left, so the next time it attaches you get a summary.
201      await leave($)
202      return { text: 'Counting from now: the next time the phone opens this session it gets a summary.' }
203    }
204    const held = await read($, away)
205    return {
206      text:
207        `Phone attached: ${(await read($, isPhone)) ? 'yes' : 'no'}. ` +
208        (held === null
209          ? 'Not counting an absence.'
210          : `Away for ${duration((await $.clock.now()) - held.since)}: ${held.turns} turns, ` +
211            `${held.changed.length} files changed, ${(await read($, pending)).length} waiting on you.`),
212    }
213  })
214
215  on('ui.close', async ($, e, next) => {
216    const result = await next(e)
217    if (e.id === MONITOR_PANE) await update($, isMonitorOpen, () => false)
218    if (e.id === AWAY_PANE) await update($, summary, () => null)
219
220    return result
221  })
222
223  on('turn.start', async ($, e, next) => {
224    if (e.agentId === undefined) {
225      const at = await $.clock.now()
226      await update($, turnStartedAt, () => at)
227      await update($, now, () => at)
228    }
229
230    return next(e)
231  })
232
233  on('turn.complete', async ($, e, next) => {
234    if (e.agentId !== undefined) return next(e)
235    await update($, turnStartedAt, () => null)
236    await update($, steps, () => [])
237    // Nothing outlives its turn, even a call the engine never reported back on.
238    await update($, pending, () => [])
239    await update($, away, held => (held === null ? null : { ...held, turns: held.turns + 1 }))
240    const usage = await $.session.usage()
241    await update($, gauges, () => readGauges(usage))
242    await notifyAway($, e.isAborted ? 'Claude stopped.' : 'Claude finished a turn.')
243
244    return next(e)
245  })
246
247  on('session.measure', async ($, e, next) => {
248    await update($, gauges, () => readGauges(e))
249
250    return next(e)
251  })
252
253  on('tool.check', async ($, e, next) => {
254    const verdict = await next(e)
255    // Only a real call carries an id; tracked while you are here too, so an absence that
256    // starts mid-approval still lists it.
257    const id = e.tool_use_id
258    if (verdict.decision === 'ask' && id !== undefined) {
259      const item = { id, text: `Approve ${describeCall(e.tool, e.input)}` }
260      await update($, pending, list => [...list.filter(one => one.id !== id), item])
261      await notifyAway($, `Waiting for your OK: ${describeCall(e.tool, e.input)}`)
262    }
263
264    return verdict
265  })
266
267  on('tool.call', async ($, e, next) => {
268    const input = callInput(e)
269    const id = e.tool_use_id
270    const isMainLoop = e.agentId === undefined
271    if (isMainLoop) await update($, steps, list => [...list, { id, text: describeCall(e.tool, input) }])
272    if (e.tool === 'AskUserQuestion') {
273      const item = { id, text: firstQuestion(input) }
274      await update($, pending, list => [...list, item])
275      await notifyAway($, item.text)
276    }
277    const isShell = e.tool === 'Bash'
278    if (isShell) shellsRunning++
279    // Counted until its checkout scan below is done, so a phone coming back meanwhile reconciles.
280    try {
281      const ran = await next(e)
282      // Finished, answered, approved or denied (here or on another surface): no longer
283      // running and no longer waiting on you.
284      if (isMainLoop) await update($, steps, list => list.filter(one => one.id !== id))
285      await update($, pending, list => list.filter(one => one.id !== id))
286      const isDenied = 'deny' in ran && ran.deny !== undefined
287      if (isDenied) return ran
288      const isFailed = ran.isError === true
289
290      const command = field(input, 'command')
291      if (e.tool === 'Bash' && command !== undefined && isTestCommand(command)) {
292        const run = readTestRun(command, outputText('result' in ran ? ran.result : undefined), isFailed)
293        await update($, lastTest, () => run)
294        await update($, away, held => (held === null ? null : { ...held, tests: [...held.tests, run] }))
295      } else if (isFailed) {
296        await update($, away, held => (held === null ? null : { ...held, errors: held.errors + 1 }))
297      }
298
299      const path = field(input, 'file_path') ?? field(input, 'notebook_path')
300      if (!isFailed && path !== undefined && WRITE_TOOLS.includes(e.tool)) {
301        await update($, away, held =>
302          held === null || held.changed.includes(path) ? held : { ...held, changed: [...held.changed, path] },
303        )
304      }
305      // A shell command can change files too (sed -i, a formatter, a generator, a commit):
306      // compare the checkout with how it was when the phone left, whenever the call began.
307      const then = e.tool === 'Bash' ? (await read($, away))?.tree : null
308      if (then !== null && then !== undefined) {
309        const touched = await changedSince($, then)
310        await update($, away, held => {
311          if (held === null) return null
312          const added = touched.filter(one => !held.changed.includes(one))
313          return added.length === 0 ? held : { ...held, changed: [...held.changed, ...added] }
314        })
315      }
316
317      return ran
318    } finally {
319      if (isShell) shellsRunning--
320    }
321  })
322
323  on('ui.render', { component: 'Pane', requestId: AWAY_PANE }, async ($, e) => {
324    const { Box, Text, Button } = $.ui.resolve(e)
325    const seen = await read($, summary)
326    if (seen === null) return <Text dimColor>Nothing happened while you were away.</Text>
327    const waiting = await read($, pending)
328    const isWide = e.props.bodyColumns >= BREAKPOINT
329    const latestTest = seen.tests[seen.tests.length - 1]
330    const shown = seen.changed.slice(isWide ? -10 : -4)
331
332    return (
333      <Box flexDirection="column" gap="sm">
334        <Text bold>Away {duration(seen.until - seen.since)}</Text>
335        <Text>
336          {seen.turns} turn{seen.turns === 1 ? '' : 's'} finished · {seen.changed.length} file
337          {seen.changed.length === 1 ? '' : 's'} changed
338          {seen.errors > 0 ? ` · ${seen.errors} error${seen.errors === 1 ? '' : 's'}` : ''}
339        </Text>
340        {latestTest !== undefined && (
341          <Text color={latestTest.isPassing ? 'green' : 'red'}>{testLine(latestTest)}</Text>
342        )}
343        {waiting.length > 0 && (
344          <Box key="waiting" flexDirection="column" borderStyle="round" paddingX="sm">
345            <Text bold>Waiting on you</Text>
346            {waiting.map(item => (
347              <Text key={item.id} wrap="truncate-end">
348                {item.text}
349              </Text>
350            ))}
351          </Box>
352        )}
353        {shown.length > 0 && (
354          <Box key="files" flexDirection="column">
355            {shown.map(path => (
356              <Text key={path} dimColor wrap="truncate-start">
357                {isWide ? path : baseName(path)}
358              </Text>
359            ))}
360            {seen.changed.length > shown.length && (
361              <Text dimColor>and {seen.changed.length - shown.length} more</Text>
362            )}
363          </Box>
364        )}
365        <Box key="actions" flexDirection="row" flexWrap="wrap" gap="sm">
366          <Button
367            key="review"
368            label="Review changes"
369            variant="primary"
370            onPress={() =>
371              void $.prompt
372                .submit({ text: reviewPrompt(seen.changed, seen.until - seen.since) })
373                .catch(() => undefined)
374            }
375          />
376          <Button key="monitor" label="Monitor" onPress={() => openMonitor($)} />
377          <Button
378            key="dismiss"
379            label="Got it"
380            role="dismiss"
381            onPress={async () => {
382              await update($, summary, () => null)
383              await $.ui.close({ id: AWAY_PANE })
384            }}
385          />
386        </Box>
387      </Box>
388    )
389  })
390
391  on('ui.render', { component: 'Pane', requestId: MONITOR_PANE }, async ($, e) => {
392    const { Box, Text } = $.ui.resolve(e)
393    const columns = e.props.bodyColumns
394    const isWide = columns >= BREAKPOINT
395    const startedAt = await read($, turnStartedAt)
396    const current = (await read($, steps)).slice(-1)[0]?.text
397    const test = await read($, lastTest)
398    const meters = await read($, gauges)
399    const at = await read($, now)
400    // About 7 px a column; unfolded, the gauges sit in the 45% column beside the status.
401    const meterColumns = isWide ? Math.floor(columns * 0.45) - 2 : columns
402    const width = Math.max(120, Math.min(320, meterColumns * 7))
403    // An SVG bar where the surface draws one (not the terminal), else the figure as text.
404    const gauge = (key: string, label: string, percent: number) => {
405      const alt = `${label} ${Math.round(percent)}%`
406      if (e.surface !== 'terminal') {
407        const { Svg } = $.ui.resolve(e)
408        return <Svg key={key} source={gaugeSvg(label, percent, width)} alt={alt} />
409      }
410      return (
411        <Text key={key} dimColor>
412          {alt}
413        </Text>
414      )
415    }
416
417    const status = (
418      <Box key="status" flexDirection="column">
419        <Text size="title" bold>
420          {startedAt === null ? 'Idle' : clock(at - startedAt)}
421        </Text>
422        <Text dimColor wrap="truncate-end">
423          {startedAt === null ? 'Waiting for your next prompt' : (current ?? 'Thinking')}
424        </Text>
425        {test === null ? (
426          <Text dimColor>No test run yet</Text>
427        ) : (
428          <Text size="heading" color={test.isPassing ? 'green' : 'red'}>
429            {testLine(test)}
430          </Text>
431        )}
432      </Box>
433    )
434
435    const meterBox = (
436      <Box key="meters" flexDirection="column" gap="sm">
437        {meters.contextPercent !== null && gauge('context', 'Context', meters.contextPercent)}
438        {meters.ratePercent !== null && gauge('rate', 'Rate limit', meters.ratePercent)}
439        {meters.costUsd !== null && <Text dimColor>Cost so far ${meters.costUsd.toFixed(2)}</Text>}
440      </Box>
441    )
442
443    return isWide ? (
444      <Box flexDirection="row" gap="md">
445        <Box width="55%">{status}</Box>
446        <Box width="45%">{meterBox}</Box>
447      </Box>
448    ) : (
449      <Box flexDirection="column" gap="md">
450        {status}
451        {meterBox}
452      </Box>
453    )
454  })
455}
456
hooks/lib.ts 147 lines
1import type { Away, TestRun, TreeSnapshot } from '../types'
2
3// Below this many columns the phone is folded; above it, unfolded.
4export const BREAKPOINT = 56
5
6const TEST_COMMAND = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(pytest|jest|vitest|mocha|rspec|phpunit)\b|\b(go|cargo|swift|dotnet|mix)\s+test\b|\bplugin test\b|\bmake\s+(test|check)\b/
7
8export const isTestCommand = (command: string) => TEST_COMMAND.test(command)
9
10const count = (text: string, pattern: RegExp): number | null => {
11  let total: number | null = null
12  for (const match of text.matchAll(pattern)) total = (total ?? 0) + Number(match[1])
13  return total
14}
15
16// Count from the runner's test summary line when it prints one (Jest's `Tests:`), so suite
17// totals (`Test Suites: 1 passed`) are not added in; otherwise from every line but suite lines.
18const countedText = (output: string) => {
19  const lines = output.split('\n')
20  const summary = lines.filter(line => /^\s*Tests?:/i.test(line))
21  return (summary.length > 0 ? summary : lines.filter(line => !/\bsuites?\b/i.test(line))).join('\n')
22}
23
24export const readTestRun = (command: string, output: string, isError: boolean): TestRun => {
25  const text = countedText(output)
26  const passed = count(text, /(\d+)\s+(?:pass(?:ed|ing)?|✓)/gi)
27  const failed = count(text, /(\d+)\s+(?:fail(?:ed|ing|ures?)?|✗)/gi)
28  return { command, isPassing: !isError && (failed ?? 0) === 0, passed, failed }
29}
30
31export const field = (input: unknown, name: string): string | undefined => {
32  if (typeof input !== 'object' || input === null) return undefined
33  const value = (input as Record<string, unknown>)[name]
34  return typeof value === 'string' ? value : undefined
35}
36
37// A tool.call's arguments arrive under `input`; fall back to the event itself.
38export const callInput = (e: unknown): unknown => {
39  const input = typeof e === 'object' && e !== null ? (e as { input?: unknown }).input : undefined
40  return typeof input === 'object' && input !== null ? input : e
41}
42
43export const baseName = (path: string) => path.split('/').filter(Boolean).pop() ?? path
44
45export const describeCall = (tool: string, input: unknown): string => {
46  const command = field(input, 'command')
47  if (command !== undefined) return `${tool} · ${command.split('\n')[0] ?? ''}`
48  const path = field(input, 'file_path') ?? field(input, 'notebook_path')
49  if (path !== undefined) return `${tool} · ${baseName(path)}`
50  const pattern = field(input, 'pattern') ?? field(input, 'description')
51  return pattern === undefined ? tool : `${tool} · ${pattern}`
52}
53
54export const firstQuestion = (input: unknown): string => {
55  const questions = (input as { questions?: unknown })?.questions
56  if (Array.isArray(questions)) {
57    const text = field(questions[0], 'question')
58    if (text !== undefined) return text
59  }
60  return 'Claude has a question for you'
61}
62
63export const outputText = (result: unknown): string => {
64  if (typeof result === 'string') return result
65  return [field(result, 'stdout'), field(result, 'stderr')].filter(Boolean).join('\n')
66}
67
68export const emptyAway = (since: number, id = 0, tree: TreeSnapshot | null = null): Away => ({
69  id,
70  since,
71  turns: 0,
72  changed: [],
73  tests: [],
74  errors: 0,
75  tree,
76})
77
78// `git status --porcelain -z` entries as repo-relative paths; a rename or copy carries its old name next.
79export const dirtyPaths = (porcelain: string): string[] => {
80  const fields = porcelain.split('\0').filter(Boolean)
81  const paths: string[] = []
82  for (let i = 0; i < fields.length; i++) {
83    const entry = fields[i] ?? ''
84    paths.push(entry.slice(3))
85    if (/[RC]/.test(entry.slice(0, 2))) i++
86  }
87  return paths
88}
89
90export const hasNews = (away: Away) =>
91  away.turns > 0 || away.changed.length > 0 || away.tests.length > 0 || away.errors > 0
92
93// Names the files the absence recorded, so other edits in the tree are not mixed in.
94export const reviewPrompt = (changed: readonly string[], awayMs: number) => {
95  const head = `I was away for ${duration(awayMs)}.`
96  const ask = 'then anything that needs my decision.'
97  if (changed.length === 0) return `${head} Summarize what you did in that time in short bullets, ${ask}`
98  const listed = changed.slice(0, 30).map(path => `- ${path}`)
99  if (changed.length > 30) listed.push(`- and ${changed.length - 30} more`)
100  return `${head} You changed these files in that time:\n${listed.join('\n')}\nSummarize the change to each in one short bullet, ${ask}`
101}
102
103// Paths whose mark differs between two snapshots, including ones in only one: a file that
104// was changed and then committed or reverted counts as well as a newly changed one.
105export const changedBetween = (before: Record<string, string>, after: Record<string, string>) =>
106  [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(path => before[path] !== after[path])
107
108export const duration = (ms: number) => {
109  const minutes = Math.max(0, Math.round(ms / 60_000))
110  if (minutes < 1) return 'under a minute'
111  if (minutes < 60) return `${minutes} min`
112  const hours = Math.floor(minutes / 60)
113  return `${hours} h ${minutes % 60} min`
114}
115
116export const clock = (ms: number) => {
117  const seconds = Math.max(0, Math.floor(ms / 1000))
118  const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
119  const ss = String(seconds % 60).padStart(2, '0')
120  return `${mm}:${ss}`
121}
122
123export const testLine = (run: TestRun) => {
124  const counts = [
125    run.passed === null ? null : `${run.passed} passed`,
126    run.failed === null ? null : `${run.failed} failed`,
127  ].filter(Boolean)
128  const verdict = run.isPassing ? 'Tests green' : 'Tests failing'
129  return counts.length === 0 ? verdict : `${verdict} · ${counts.join(', ')}`
130}
131
132const gaugeColor = (percent: number) => (percent < 60 ? '#2e9e5b' : percent < 85 ? '#d18b00' : '#d23f31')
133
134// A labelled bar: the track is a neutral grey that reads in light and dark.
135export const gaugeSvg = (label: string, percent: number, width: number) => {
136  const clamped = Math.max(0, Math.min(100, percent))
137  const fill = Math.round((clamped / 100) * width)
138  return (
139    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="34" viewBox="0 0 ${width} 34">` +
140    `<text x="0" y="12" font-family="-apple-system, system-ui, sans-serif" font-size="12" fill="#8a8a8e">${label}</text>` +
141    `<text x="${width}" y="12" text-anchor="end" font-family="-apple-system, system-ui, sans-serif" font-size="12" fill="#8a8a8e">${Math.round(clamped)}%</text>` +
142    `<rect x="0" y="18" width="${width}" height="12" rx="6" fill="#8a8a8e" fill-opacity="0.25"/>` +
143    `<rect x="0" y="18" width="${fill}" height="12" rx="6" fill="${gaugeColor(clamped)}"/>` +
144    `</svg>`
145  )
146}
147
types/index.d.ts 40 lines
1export type TestRun = { command: string; isPassing: boolean; passed: number | null; failed: number | null }
2
3// One line about a tool call, keyed by its id: something waiting on you, or a step in progress.
4export type CallLine = { id: string; text: string }
5
6// The checkout when the phone left: HEAD, and each changed file marked by size and mtime.
7export type TreeSnapshot = { root: string; head: string | null; marks: Record<string, string> }
8
9export type Away = {
10  // Which departure opened it, so a reconnect closes only the absence it saw.
11  id: number
12  since: number
13  turns: number
14  changed: string[]
15  tests: TestRun[]
16  errors: number
17  tree: TreeSnapshot | null
18}
19
20export type Gauges = { contextPercent: number | null; ratePercent: number | null; costUsd: number | null }
21
22declare module 'claude-code' {
23  interface PluginState {
24    'duo-glance': {
25      isPhone: boolean
26      away: Away | null
27      summary: (Away & { until: number }) | null
28      // Approvals and questions still open, kept whether or not the phone is away.
29      pending: CallLine[]
30      // Main-loop tool calls running now, oldest first.
31      steps: CallLine[]
32      turnStartedAt: number | null
33      lastTest: TestRun | null
34      gauges: Gauges
35      isMonitorOpen: boolean
36      now: number
37    }
38  }
39}
40