SLOPSHOPPER

own-bash-edit-notice

Shorten the 'changed on disk' notice when the change came from this session's own Bash command

newguardprompt
A shopper browsing a rack in a slop shop
README

own-bash-edit-notice (Claude Mod)

After a session changes a file it had Written/Edited through its own Bash command (python3 - <<EOF rewrites, sed -i, cat >> f), the engine injects an edited_text_file notice with a diff snippet of up to 8 KB. The session made the change, so the snippet is mostly repeated context: in the author's 2026-08/09 session archive, 413 of 738 such notices (56%) followed a Bash call of the same session naming the file, ~600k tokens.

Swallowing a real outside change (another session editing the file in this shared tree) is worse than paying the tokens, so the notice is cut to one line only when all hold:

  • a Bash command of the same loop ran since the previous model request (turn.step counts requests; the notice is computed for the first request after a change);
  • it names the file by absolute path, ~ path or the path as the notice shows it, or by its path relative to $.session.cwd() or to a directory the command cds into (cd ~/x && python3 - <<EOF … Path("sub/f.py"));
  • it plausibly wrote it: a redirect into that path, or a writing verb (sed -i, tee, mv/cp, patch, truncate, git checkout/restore/apply/stash/reset, a python/node/perl/ruby script) in the command naming it. cat, jq ., git diff, grep don't count.

Everything else passes through untouched. The transcript keeps the engine's record; only the request changes. Residual risk: a writing command naming the file (e.g. cp f /tmp/x, f being the source) in the same step as an outside edit of f.

Needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (Claude Mods early access, 2.1.271+) and the plugin installed from this repo's mods/ marketplace (see ../README.md): claude plugin install own-bash-edit-notice@claude-code-patches (an installed plugin reaches tmux teammates; --plugin-dir doesn't). Installs are version-keyed copies: bump version in .claude-plugin/plugin.json and claude plugin update after editing the mod. API constraints met along the way: no imports but relative files and "claude-code"; turn.step needs an async function* that return yield* next(e); $ must be spelled $.noun.event(...) at the call site (no $.ui?.log).

Probed 2026-09-25 on 2.1.280 (probe.sh: haiku, requests captured by probe_logproxy.py, an outside editor process triggered by marker files): f.txt python3 -c "…f.txt…" → one line; g.txt changed by mod.sh (not named) → full; h.txt cat h.txt then an outside edit → full; k.txt sed -i … k.txt → one line; n.txt cp n.txt /tmp/… two requests before an outside edit → full. Each decision saw exactly one candidate command (the debug log's own-bash-edit-notice: lines), so turn.step fires before the request's attachments resolve. prompt.attachment ~1.4 ms.

0.0.3 (2026-09-26): the ~ form never matched before, because a hooks module has no process (HOME now comes from $.env.get("HOME")), so cat >> ~/x/f <<EOF kept the full notice. Re-probed on 2.1.280 with a ~-path heredoc append, with and without tmux-teammate CLI flags (--agent-id/--team-name): shortened both times.

0.0.4 (2026-09-29): replay.mts runs wrote() over every archived notice. Over 176 notices from 09-26 to 09-29, 0.0.3 shortened about 1 in 6. Most misses were cd <dir> && python3 - <<EOF edits naming the file relative to that dir (or ~/dir + basename, which the absolute-only directory check missed). 0.0.4 resolves cd targets and the cwd instead, and shortens 122 of the 176. Of the notices still kept, those with a same-request command naming the file are real outside changes: Overleaf git pulls, background-task output files, the probe's outside editor. The older basename+directory rule is gone: with the ~ form added it matched c.py inside xc.py. test_wrote.mts holds the positive/negative naming cases; probe.sh gained sub/q.txt (cd + relative sed -i), and passes on 2.1.283 with --plugin-dir and installed.

Also checked: the CLI's own "Bash edit diff" (CLAUDE_CODE_BASH_EDIT_DIFF=1, bashEditDiffEnabled) records a bashEditDiff in the tool result but does not stop the notice.

Source 1 files
hooks/register.ts 96 lines
1// The engine's `edited_text_file` notice ("Note: <file> changed on disk since you last
2// read it…" plus a diff snippet) also fires when the session changed the file itself
3// through Bash (python/sed/heredoc). When the session made the change the snippet is
4// up to 8 KB of repeated context; when someone else did, it is the only way the session
5// learns the file moved under it, so swallowing a real outside change is the dangerous
6// direction. The notice is cut to one line only when ALL of these hold:
7//   - a Bash command of this loop ran since the previous model request (the notice is
8//     computed for the first request after the change);
9//   - that command names the file by its absolute path, `~` path or the path as the
10//     notice shows it, or by its path relative to the session cwd or to a directory the
11//     command `cd`s into;
12//   - it plausibly WROTE it: a redirect into that path, or a writing verb (sed -i, tee,
13//     mv/cp, patch, truncate, git checkout/restore/apply/stash/reset, a python/node/perl/
14//     ruby script) in a command naming it. `cat`, `jq .`, `git diff`, `grep` don't count.
15
16const WRITE_VERB = /\bsed\s+(?:-\S+\s+)*-i|\bperl\s+-\S*i|\btee\b|\bmv\b|\bcp\b|\bpatch\b|\btruncate\b|\bgit\s+(?:checkout|restore|apply|stash|reset)\b|\bpython3?\b|\bnode\b|\bruby\b|\bperl\b/
17const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
18
19type Ran = { cmd: string; step: number }
20const steps = new Map<string, number>() // loop -> model requests sent so far
21const ran = new Map<string, Ran[]>()   // loop -> Bash commands, tagged with the step they followed
22const loop = (e: any): string => e.agentId ?? "main"
23
24function forms(shown: string, abs: string, home: string): string[] {
25  const out = new Set([shown, abs])
26  if (home && abs.startsWith(home + "/")) out.add("~" + abs.slice(home.length))
27  return [...out].filter(Boolean)
28}
29
30function resolve(p: string, home: string, cwd: string): string {
31  const raw = home && (p === "~" || p.startsWith("~/")) ? home + p.slice(1) : p
32  const out: string[] = []
33  for (const s of (raw.startsWith("/") ? raw : `${cwd}/${raw}`).split("/")) {
34    if (s === "..") out.pop()
35    else if (s && s !== ".") out.push(s)
36  }
37  return "/" + out.join("/")
38}
39
40// The file's path relative to the session cwd and to every directory the command `cd`s into.
41function relatives(c: string, abs: string, home: string, cwd: string): string[] {
42  const dirs = [cwd]
43  for (const m of c.matchAll(/(?:^|[;&|(\n])\s*cd\s+(['"]?)([^\s'";&|)]+)\1/g)) dirs.push(resolve(m[2], home, cwd))
44  return [...new Set(dirs.filter((d) => d && abs.startsWith(d + "/")).map((d) => abs.slice(d.length + 1)))]
45}
46
47export function wrote(cmd: string, shown: string, abs: string, home: string, cwd: string): boolean {
48  const c = cmd.replace(/\d?>&\d|&>\s*\/dev\/null|\d?>>?\s*\/dev\/null/g, " ")
49  const names = forms(shown, abs, home)
50  const rels = relatives(c, abs, home, cwd)
51  const named = names.some((f) => c.includes(f))
52    || rels.some((r) => new RegExp(`(?:^|[\\s'"=(,:])${esc(r)}(?:$|[\\s'"),;|&:])`).test(c))
53  if (!named) return false
54  if ([...names, ...rels].some((f) => new RegExp(`>>?\\s*['"]?${esc(f)}(?:['"\\s;|&)]|$)`).test(c))) return true
55  return WRITE_VERB.test(c)
56}
57
58export function register(on: any): void {
59  on("turn.step", async function* ($: any, e: any, next: any) {
60    steps.set(loop(e), (steps.get(loop(e)) ?? 0) + 1)
61    return yield* next(e) // turn.step streams: forward the chunks untouched
62  })
63
64  on("tool.call", { tool: "Bash" }, async ($: any, e: any, next: any) => {
65    const list = ran.get(loop(e)) ?? []
66    list.push({ cmd: String(e.command ?? ""), step: steps.get(loop(e)) ?? 0 })
67    ran.set(loop(e), list.slice(-20))
68    return next(e)
69  })
70
71  on("prompt.attachment", { type: "edited_text_file" }, async ($: any, e: any, next: any) => {
72    const m = /^Note: (.+?) changed on disk since you last read it/.exec(e.text)
73    if (!m) return next(e)
74    const shown = m[1]
75    // A hooks module has no `process`: HOME comes through $.env.
76    const home = (await $.env.get("HOME")) ?? ""
77    const cwd = await $.session.cwd()
78    let abs = shown.startsWith("~/") && home ? home + shown.slice(1) : shown
79    if (!abs.startsWith("/")) abs = `${cwd}/${abs.replace(/^\.\//, "")}`
80    // turn.step for this request fires before its attachments are resolved, so the
81    // previous request is step - 1: commands tagged with it ran after that request.
82    const since = (steps.get(loop(e)) ?? 0) - 1
83    const recent = (ran.get(loop(e)) ?? []).filter((r) => r.step >= since)
84    const cmd = recent.map((r) => r.cmd).findLast((c) => wrote(c, shown, abs, home, cwd))
85    $.ui.log(`own-bash-edit-notice: ${abs} at step ${steps.get(loop(e))}, ${recent.length} candidate command(s), `
86      + (cmd === undefined ? "full notice kept" : "shortened"))
87    if (cmd === undefined) return next(e)
88    const head = cmd.replace(/\s+/g, " ").slice(0, 80)
89    return next({
90      ...e,
91      text: `Note: ${shown} changed on disk after your own Bash command (\`${head}\`), so its diff is left out. `
92        + `The copy of it in your context is stale; Read it before relying on its exact current content.`,
93    })
94  })
95}
96