diff options
Diffstat (limited to 'claude-templates/.ai/scripts/inbox-send.py')
| -rwxr-xr-x | claude-templates/.ai/scripts/inbox-send.py | 82 |
1 files changed, 74 insertions, 8 deletions
diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py index 1362a1f..663efcb 100755 --- a/claude-templates/.ai/scripts/inbox-send.py +++ b/claude-templates/.ai/scripts/inbox-send.py @@ -31,6 +31,7 @@ import os import re import shutil import sys +import tempfile from datetime import datetime from pathlib import Path @@ -48,7 +49,7 @@ def resolve_roots() -> list[Path]: config = Path.home() / ".claude" / "inbox-roots.txt" if config.is_file(): paths: list[Path] = [] - for line in config.read_text().splitlines(): + for line in config.read_text(encoding="utf-8").splitlines(): line = line.strip() if line and not line.startswith("#"): paths.append(Path(line).expanduser()) @@ -69,17 +70,28 @@ def discover_projects(roots: list[Path]) -> list[Path]: a specific project root (included directly if it qualifies). """ projects: list[Path] = [] + seen: set[Path] = set() + + def _add(p: Path) -> None: + # Dedupe on the resolved path: a roots config naming both a parent and + # one of its children would otherwise list the child project twice, at + # two different indices. + key = p.resolve() + if key not in seen: + seen.add(key) + projects.append(p) + for root in roots: if not root.is_dir(): continue if _is_project(root): - projects.append(root) + _add(root) continue for child in sorted(root.iterdir()): if not child.is_dir(): continue if _is_project(child): - projects.append(child) + _add(child) return projects @@ -177,6 +189,56 @@ def build_text_org(message: str, source_name: str, timestamp: str) -> str: ) +def uniquify(dest: Path) -> Path: + """Return dest, or dest with a -2/-3/... stem suffix when it already exists. + + Two sends in the same minute whose text starts with the same phrase + derive identical filenames, and the second silently overwrote the + first (a message was lost this way, 2026-07-02). Never overwrite. + """ + if not dest.exists(): + return dest + n = 2 + while True: + candidate = dest.with_name(f"{dest.stem}-{n}{dest.suffix}") + if not candidate.exists(): + return candidate + n += 1 + + +def _atomic_write(dest: Path, writer) -> None: + """Write to a temp file in dest's directory, then rename it into place. + + dest is another project's inbox/, and a direct write truncates the target + on open, so any mid-write failure (a full disk, an encoding error, an + interrupted process) leaves a zero-byte .org there. inbox-status counts + that phantom as a pending handoff and blocks a turn in the receiving + project over a file with no content and no sender (2026-07-23). Writing to + a temp sibling and os.replace-ing means the inbox only ever sees a complete + file. os.replace is atomic within one filesystem, and the temp sits in the + same directory as dest, so it is. + + `writer` receives the open temp path and fills it. On any failure the temp + is removed and the error re-raised, so a caught error never leaves debris. + """ + fd, tmp = tempfile.mkstemp( + dir=dest.parent, prefix=".inbox-send-", suffix=dest.suffix + ) + os.close(fd) + tmp_path = Path(tmp) + # mkstemp creates the temp 0600; give the delivered file the umask-default + # mode the old direct write produced, so inbox files stay readable as before. + umask = os.umask(0) + os.umask(umask) + os.chmod(tmp_path, 0o666 & ~umask) + try: + writer(tmp_path) + os.replace(tmp_path, dest) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + def send_text( target_inbox: Path, message: str, @@ -191,8 +253,9 @@ def send_text( if not slug: raise ValueError(f"could not derive a slug from text: {message!r}") filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org" - dest = target_inbox / filename - dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) + dest = uniquify(target_inbox / filename) + body = build_text_org(message, source_name, now.strftime(TS_DOC_FMT)) + _atomic_write(dest, lambda p: p.write_text(body, encoding="utf-8")) return dest @@ -211,8 +274,8 @@ def send_file( raise ValueError(f"could not derive a slug from file: {src_path}") ext = src_path.suffix filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" - dest = target_inbox / filename - shutil.copy2(src_path, dest) + dest = uniquify(target_inbox / filename) + _atomic_write(dest, lambda p: shutil.copyfile(src_path, p)) return dest @@ -293,7 +356,10 @@ def main() -> int: else: assert args.file is not None dest = send_file(target_inbox, args.file, source_name, args.name, now) - except (ValueError, FileNotFoundError) as exc: + except (ValueError, OSError) as exc: + # OSError covers FileNotFoundError (missing source), PermissionError + # (unreadable source), and any atomic-write failure — all should + # surface as the clean "inbox-send: <message>" error, never a traceback. print(f"inbox-send: {exc}", file=sys.stderr) return 1 |
