SLOPSHOPPER

margins

Talk to Claude inside a file instead of the chat: you save, Claude answers beneath what you wrote, in your own editor, with the whole thread versioned.

bandpromptprocesstimer
v1.0.0MITupdated 2026-10-15nkyriazis/margins
A shopper browsing a rack in a slop shop
README

margins

CI License: MIT

Talk to your coding agent inside a file, in your own editor, instead of the chat.

<img src="docs/demo.gif" width="900" alt="Claude Code on the left, decisions.md open in Neovim on the right. Claude writes two open calls about cart.py into the file and starts watching it, and a band above the prompt reads 'decisions.md, turn 1, your turn, watching'. The author types 'yes' under the first item and 'clamp it to 0-100, and add a test for both' under the second, then saves. Claude's replies appear in the editor under each answer, the code is changed and tested, and the dialog closes with a summary line while the band reads 'closed after 5 turns'.">

Some work is a list of decisions: five open calls on a release, a review with a dozen comments, a plan with questions on every step. In the chat, each answer scrolls the last one away. You can't answer item 3 before item 1, and a week later the decisions are buried in a transcript.

With margins, Claude writes the open questions into a file, each with its context and a default. You answer in your editor, in any order, and save. Each save is a turn. Claude reads what you changed, replies beneath your words in the file, and acts on what you decided. The file keeps the whole thread under each item, and it has a git history of its own, so every turn can be undone.

## 1. Database for the job queue?
sqlite locks on concurrent writers, and the workers now run four at a time.
Default: postgres. OK?

   Nikos: yes, but keep sqlite for the tests, CI has no postgres.

   Claude: Done. Postgres in prod, sqlite in tests (a3f9e1c), 212 tests pass.
   Closed.

## 2. Ship on Friday?
Two flaky tests left on main. Default: ship, mark them flaky. Ship?

   Nikos: what are they?

Above the prompt, Claude Code shows where each dialog stands:

✎ release-2.0.md · turn 6 · your turn · watching

Install

In Claude Code, install the plugin:

/plugin marketplace add nkyriazis/margins
/plugin install margins@margins

Restart Claude Code. For other agents, or just the skill without the plugin, see More ways to install.

Everyday use

Ask for it when a task turns into a list of decisions:

review cart.py and put your open calls in decisions.md with margins, then watch it

Or hand Claude a file you already have:

watch notes/review.md, I'll answer in the file

Then work in your editor. Answer, ask back, write "yes", tick a box, delete a line you disagree with, and save. Claude answers in the file and writes one line per item in the chat. When every item is closed, or you write "done", Claude writes a closing line with the outcome and stops watching.

What you get

  • The file is the conversation. Each item collects its own thread, in order, right under the question. Nothing is lost to scrollback, and you can come back to it later.
  • Any editor. vim, VS Code, Obsidian, nano. A save is all margins needs.
  • Decisions are applied, not just noted. "yes" on a change means Claude makes it, then says what it did and the commit.
  • Its own history. The file gets a git history in .<name>.dialog.git next to it, one commit per turn, separate from your project's repository. git --git-dir=.<name>.dialog.git log -p shows every turn, and nothing is ever squashed.
  • Replies land in the right place. Claude places each reply by line number, after the last line you wrote in that item, never by matching text. A reply can't split the next item's heading.
  • A band above the prompt. One row per dialog says whose turn it is, and turns red when nothing is watching the file.
  • It doesn't forget to listen. A watch can expire or die. If Claude is about to stop while a dialog is open and unwatched, a hook stops it and says what to run.
  • A clear end. The dialog closes in the file and in the chat, so neither side is left wondering whether the other is still listening.

How it works

margins.sh does the mechanical parts, and the skill tells the agent how to hold the conversation.

CommandWhat it does
margins init <file>Gives the file its own git history, .<name>.dialog.git, beside it
margins watch <file>Prints one line per real save. Claude Code arms it as a Monitor. A save counts once the file has been still for two seconds and differs from the last turn, so Claude's own replies never fire it
margins turn <file>Commits your edit as a turn and prints each line you added or changed with its number, plus what you removed
margins reply <file> <line> <text-file>Inserts a reply after that line and commits it. It refuses while you have edits it hasn't read
margins end <file> "<outcome>"Writes the closing line, commits, and tags the history closed. The watch sees the tag and exits
margins reopen <file>Lifts the close

Each command also appends a line to a small state file for the agent session, in $XDG_RUNTIME_DIR/margins or /tmp/margins-<uid>. The plugin reads it once a second to draw the band, and the Stop hook reads it to find dialogs with no live watch. Nothing goes over the network.

Configuration

Set these in the environment, or under env in Claude Code's settings.json.

VariableDefaultEffect
MARGINS_BAND10 hides the band above the prompt
MARGINS_REARM10 turns off the Stop hook that keeps a dialog watched
MARGINS_DIRWhere the session state files go

Works with

Linux and macOS, bash 3.2 or newer, git and python3. The band and the Stop hook need Claude Code 2.1.287 or newer. The skill works in any agent that reads skills. Without a Monitor tool the agent runs the watch in the background and checks it between turns.

More ways to install

Other agents. npx skills add nkyriazis/margins installs the skill for Codex, Gemini CLI, opencode, Cursor, Goose and the other agents skills knows about.

From a clone. Copy the skill into your skills folder:

git clone https://github.com/nkyriazis/margins
cp -R margins/skills/margins ~/.claude/skills/

Developing. claude --plugin-dir ./margins loads the plugin from a clone. tests/run.sh runs the tests.

Uninstall

/plugin uninstall margins@margins, or remove ~/.claude/skills/margins. Dialog histories stay next to their files as .<name>.dialog.git until you delete them.

License

MIT

Source 2 files
hooks/register.ts 129 lines
1// margins as a Claude Code mod: a band above the prompt with one row per
2// dialog this session holds, saying whose turn it is and whether the file is
3// watched, redrawn from the session's state file once a second.
4//
5// MARGINS_BAND=0 turns it off.
6import { parse, row, type Dialog, type Seg } from './dialogs.ts'
7
8let sid = ''
9let file = ''
10let seen = -1
11let busy = false
12let show = true
13let hasProc = false
14let ownNs = ''
15let dialogs: Dialog[] = []
16let live = new Set<string>()
17let checked = 0
18// Closed dialogs leave the band at the first prompt the person sends after
19// they closed; the watch's own end notification is not one.
20let promptedAt = 0
21
22// The session's state file, resolved the way margins.sh resolves it
23async function locate($) {
24  show = (await $.env.get('MARGINS_BAND')) !== '0'
25  hasProc = await $.fs.exists('/proc/self/stat')
26  let dir = (await $.env.get('MARGINS_DIR')) ?? ''
27  if (dir === '') {
28    const xdg = (await $.env.get('XDG_RUNTIME_DIR')) ?? ''
29    if (xdg !== '' && (await $.fs.exists(xdg))) {
30      dir = xdg + '/margins'
31    } else {
32      const tmp = (await $.env.get('TMPDIR')) || '/tmp'
33      const id = await $.process.run(['id', '-u'])
34      dir = tmp.replace(/\/$/, '') + '/margins-' + id.stdout.trim()
35    }
36  }
37  if (hasProc) {
38    const link = await $.process.run(['readlink', '/proc/self/ns/pid'])
39    ownNs = link.stdout.replace(/[^0-9]/g, '')
40  }
41  file = dir + '/' + sid.replace(/[^A-Za-z0-9._-]/g, '_') + '.log'
42  seen = -1
43  dialogs = []
44}
45
46// A watch whose process is gone no longer sees saves. Across pid namespaces
47// (a sandbox) the pid means nothing here, so it counts as alive.
48async function alive($, d: Dialog): Promise<boolean> {
49  if (!d.pid) return false
50  if (d.ns && ownNs && d.ns !== ownNs) return true
51  if (hasProc) return $.fs.exists('/proc/' + d.pid)
52  const r = await $.process.run(['kill', '-0', d.pid])
53  return r.exitCode === 0
54}
55
56async function refresh($) {
57  if (busy) return
58  busy = true
59  try {
60    await tick($)
61  } finally {
62    busy = false
63  }
64}
65
66async function tick($) {
67  const now = (await $.clock.now()) / 1000
68  const id = String(await $.session.id())
69  if (id !== sid) {
70    sid = id
71    await locate($)
72  }
73  const stat = await $.fs.stat(file).catch(() => undefined)
74  let changed = false
75  if (!stat) {
76    changed = dialogs.length > 0
77    dialogs = []
78  } else if (stat.mtimeMs !== seen) {
79    seen = stat.mtimeMs
80    dialogs = parse(await $.fs.read(file).catch(() => ''))
81    changed = true
82  }
83  if (dialogs.length > 0 && (changed || now - checked >= 2)) {
84    checked = now
85    const next = new Set<string>()
86    for (const d of dialogs) if (await alive($, d)) next.add(d.path)
87    if (next.size !== live.size || [...next].some((p) => !live.has(p))) changed = true
88    live = next
89  }
90  if (changed) $.ui.invalidate('ui.render')
91}
92
93const shown = () => dialogs.filter((d) => d.phase !== 'closed' || d.at >= promptedAt)
94
95export function register(on) {
96  on('session.start', async ($, e, next) => {
97    $.clock.every(1000, () => refresh($))
98    return next(e)
99  })
100
101  on('prompt.submit', async ($, e, next) => {
102    try {
103      if (e.origin?.kind !== 'composer' && e.origin?.kind !== 'bridge') return next(e)
104      promptedAt = (await $.clock.now()) / 1000
105      if (dialogs.some((d) => d.phase === 'closed')) $.ui.invalidate('ui.render')
106    } catch {
107      // The band is a courtesy; the prompt goes through regardless.
108    }
109    return next(e)
110  })
111
112  // One row per dialog, with anything other mods draw there kept below.
113  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114    const rows = shown()
115    if (!show || rows.length === 0 || e.props.hasSurvey) return next(e)
116    const { Box, Text } = $.ui.resolve(e)
117    const draw = (seg: Seg) =>
118      seg.color || seg.dim || seg.bold
119        ? Text({ color: seg.color, dimColor: seg.dim, bold: seg.bold, children: [seg.text] })
120        : seg.text
121    const room = Math.max(1, e.props.maxRows - 1)
122    const lines = rows
123      .slice(-room)
124      .map((d) => Text({ wrap: 'truncate-end', children: row(d, live.has(d.path), e.props.isWorking).map(draw) }))
125    const theirs = await next(e)
126    return Box({ flexDirection: 'column', children: theirs ? [...lines, theirs] : lines })
127  })
128}
129
hooks/dialogs.ts 83 lines
1// The state file margins.sh appends to, read back as one entry per dialog.
2// Each line is `<epoch> TAB <event> TAB <absolute path> TAB <detail>`.
3
4export type Phase = 'yours' | 'saved' | 'claude' | 'closed'
5
6export type Dialog = {
7  path: string
8  name: string
9  phase: Phase
10  turn: number
11  // The watch armed last, while it has not said it ended
12  pid: string
13  ns: string
14  at: number
15}
16
17export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
18
19export function parse(text: string): Dialog[] {
20  const byPath = new Map<string, Dialog>()
21  for (const line of text.split('\n')) {
22    const [at, event, path, detail = ''] = line.split('\t')
23    if (!path) continue
24    let d = byPath.get(path)
25    if (!d) {
26      d = { path, name: path.split('/').pop() ?? path, phase: 'yours', turn: 0, pid: '', ns: '', at: 0 }
27      byPath.set(path, d)
28    }
29    d.at = Number(at) || d.at
30    const n = Number(detail)
31    switch (event) {
32      case 'open':
33      case 'reply':
34        d.phase = 'yours'
35        break
36      case 'save':
37        d.phase = 'saved'
38        break
39      case 'turn':
40        d.phase = 'claude'
41        break
42      case 'closed':
43        d.phase = 'closed'
44        d.pid = ''
45        break
46      case 'watch': {
47        const [pid = '', ns = ''] = detail.split(' ')
48        d.pid = pid
49        d.ns = ns
50        if (d.phase === 'closed') d.phase = 'yours'
51        continue
52      }
53      case 'unwatch':
54        if (d.pid === detail) d.pid = ''
55        continue
56      default:
57        continue
58    }
59    if (event !== 'save' && Number.isFinite(n) && detail !== '') d.turn = n
60  }
61  return [...byPath.values()]
62}
63
64// One row of the band: what the dialog waits for, and whether a save will be seen.
65export function row(d: Dialog, watching: boolean, working: boolean): Seg[] {
66  const head: Seg[] = [
67    { text: d.phase === 'closed' ? '✔ ' : '✎ ', color: d.phase === 'closed' ? 'green' : 'magenta' },
68    { text: d.name, bold: d.phase !== 'closed', dim: d.phase === 'closed' },
69  ]
70  if (d.phase === 'closed') {
71    return [...head, { text: ` · closed after ${d.turn} turns`, dim: true }]
72  }
73  const segs: Seg[] = [...head, { text: ` · turn ${d.turn} · `, dim: true }]
74  // A turn that needed no reply leaves no event behind, so Claude only holds
75  // the turn while it is working.
76  if (d.phase === 'yours' || (d.phase === 'claude' && !working)) segs.push({ text: 'your turn', color: 'green' })
77  if (d.phase === 'saved') segs.push({ text: 'you saved, Claude is reading', color: 'yellow' })
78  if (d.phase === 'claude' && working) segs.push({ text: 'Claude is answering in the file', color: 'cyan' })
79  segs.push({ text: ' · ', dim: true })
80  segs.push(watching ? { text: 'watching', dim: true } : { text: 'not watched, a save goes unanswered', color: 'red' })
81  return segs
82}
83