SLOPSHOPPER

sign-wait

Notifies you when 1Password blocks a git commit or push, with a one-press retry

newbandguardtoastprocess
★ 3v0.1.0no licenseupdated 2026-10-06ewels/dotfiles/claude-mods/sign-wait
A shopper browsing a rack in a slop shop
README

dotfiles

Personal dotfiles for sharing setups and configs between machines.

Feel free to steal or use as you like.. Let me know if I'm missing any good tricks!

Source 2 files
hooks/register.tsx 130 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SignBlock } from '../types'
5
6const block = atom({ plugin: 'sign-wait', key: 'block' } as const, null)
7
8// Commit signing (op-ssh-sign) and SSH push auth both go through the 1Password agent.
9const AGENT_FAILED = /1Password:|sign_and_send_pubkey: signing failed|communication with agent failed/
10const GIT_WRITE = /\bgit\s+(commit|push|rebase|cherry-pick|merge|tag)\b/
11const RENOTIFY_MS = 10 * 60_000
12
13let lastNotifiedAt = 0
14
15// Claude often runs `cd other/repo && git commit`; follow it there.
16async function repoDir($: EngineInterface, command: string, fallback: string) {
17  const m = command.match(/(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|[^\s;&]+)/) ?? command.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
18  if (!m?.[1]) return fallback
19  let p = m[1].replace(/^['"]|['"]$/g, '')
20  if (p.startsWith('~')) p = ((await $.env.get('HOME')) ?? '') + p.slice(1)
21  return p.startsWith('/') ? p : `${fallback}/${p}`
22}
23
24function describe(b: SignBlock) {
25  return b.kind === 'unsigned'
26    ? `${b.unsigned?.length} unsigned commit${b.unsigned?.length === 1 ? '' : 's'} held back from push`
27    : `1Password blocked a git ${b.kind}`
28}
29
30// ponytail: checks every commit not on any remote, not just the pushed refspec; parse the refspec if pushing other branches matters
31async function unsignedCommits($: EngineInterface, dir: string) {
32  const log = await $.process.run(['git', 'log', '--reverse', '--format=%h %G?', 'HEAD', '--not', '--remotes'], { cwd: dir })
33  if (log.exitCode !== 0) return []
34  return log.stdout.split('\n').filter(l => l.endsWith(' N')).map(l => l.slice(0, -2))
35}
36
37async function blocked($: EngineInterface, b: SignBlock) {
38  await update($, block, () => b)
39  $.ui.toast(`${describe(b)}. Unlock 1Password, then press Retry.`, { timeoutMs: 15_000 })
40  if (b.at - lastNotifiedAt < RENOTIFY_MS) return
41  lastNotifiedAt = b.at
42  // A macOS notification reaches you away from the terminal; the toast doesn't.
43  await $.process.run([
44    'osascript', '-e',
45    `display notification "${describe(b)}. Unlock 1Password, then Retry." with title "Claude Code: signing" sound name "Glass"`,
46  ]).catch(() => undefined)
47}
48
49// An unsigned-commits block stays until a push gets through, i.e. the commits were re-signed.
50async function cleared($: EngineInterface, isPush: boolean) {
51  const b = await read($, block)
52  if (!b || (b.kind === 'unsigned' && !isPush)) return
53  await update($, block, () => null)
54  $.ui.toast('1Password signing works again')
55}
56
57async function retry($: EngineInterface, b: SignBlock) {
58  await update($, block, () => null)
59  const text =
60    b.kind === 'unsigned'
61      ? `1Password is unlocked now. In ${b.dir}, re-sign the unsigned commits (${b.unsigned?.join(', ')}) with \`git rebase --exec 'git commit --amend --no-edit -n -S' ${b.unsigned?.[0]}^\`, check \`git log --format='%h %G?' HEAD --not --remotes\` shows no N, then push.`
62      : `1Password is unlocked now. Retry the git ${b.kind} that failed in ${b.dir}, then carry on.`
63  await $.prompt.submit({ text, asUser: true })
64}
65
66function ago(ms: number) {
67  const min = Math.round(ms / 60_000)
68  return min < 1 ? 'just now' : min < 60 ? `${min} min ago` : `${Math.round(min / 60)} h ago`
69}
70
71export const register: Register = on => {
72  // Fails open: if the unsigned check itself errors, the push goes ahead.
73  on('tool.call', async ($, e, next) => {
74    if (e.tool !== 'Bash' || !GIT_WRITE.test(e.command)) return next(e)
75
76    if (/\bgit\s+push\b/.test(e.command)) {
77      const dir = await repoDir($, e.command, await $.session.cwd())
78      const unsigned = await unsignedCommits($, dir)
79      // The check runs before the command, so an unsigned commit made earlier in it would slip past.
80      if (/commit\.gpgsign=false|--no-gpg-sign/.test(e.command)) {
81        return { deny: 'sign-wait: commit unsigned and push in separate commands, so the push can be checked for unsigned commits.' }
82      }
83      if (unsigned.length > 0) {
84        await blocked($, { dir, kind: 'unsigned', at: await $.clock.now(), unsigned })
85        return {
86          deny: `sign-wait: push held back, ${unsigned.length} commit(s) are unsigned (${unsigned.join(', ')}). The user has been notified and will press Retry to re-sign and push once 1Password is unlocked. Do not push them unsigned; carry on with other work.`,
87        }
88      }
89    }
90
91    const ran = await next(e)
92
93    if (ran.isError === true && AGENT_FAILED.test(ran.text ?? '')) {
94      const dir = await repoDir($, e.command, await $.session.cwd())
95      const kind = /\bgit\s+push\b/.test(e.command) && !/\bgit\s+commit\b/.test(e.command) ? 'push' : 'commit'
96      await blocked($, { dir, kind, at: await $.clock.now() })
97      return {
98        ...ran,
99        context: [
100          ...(ran.context ?? []),
101          "sign-wait: the 1Password agent is locked or away and the user has been notified. Commit unsigned to keep going (`git -c commit.gpgsign=false commit ...`); pushes are held back until those commits are re-signed.",
102        ],
103      }
104    }
105    if (ran.isError !== true && ran.deny === undefined) await cleared($, /\bgit\s+push\b/.test(e.command))
106    return ran
107  }).catch(($, e, next) => next(e))
108
109  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110    const b = await read($, block)
111    if (!b || e.props.hasSurvey) return next(e)
112    const { Box, Button, Text } = $.ui.resolve(e)
113    const now = await $.clock.now()
114    const below = await next(e)
115    return (
116      <Box flexDirection="column">
117        <Box>
118          <Text color="yellow">🔒 {describe(b)} </Text>
119          <Text dimColor>
120            in {b.dir.split('/').pop()} · {ago(now - b.at)}{' '}
121          </Text>
122          <Button key="sign-retry" label="Retry" variant="primary" onPress={() => retry($, b)} />
123          <Button key="sign-dismiss" label="Dismiss" role="dismiss" onPress={() => update($, block, () => null)} />
124        </Box>
125        {below}
126      </Box>
127    )
128  })
129}
130
types/index.d.ts 14 lines
1export type SignBlock = {
2  dir: string
3  kind: 'commit' | 'push' | 'unsigned'
4  at: number
5  /** For `unsigned`: the commits a push would have sent unsigned, oldest first. */
6  unsigned?: string[]
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'sign-wait': { block: SignBlock | null }
12  }
13}
14