#!/usr/bin/env python3 """依頼箱の見張り係(inbox_runner) 依頼箱/新規/ に置かれた依頼(.md)を1件ずつ Claude Code(claude -p)に渡し、 Claude Code の報告を 依頼箱/結果/ に書き戻す。 使い方: python3 inbox_runner.py 見張りを続ける(ターミナルの窓を開けたままにする) python3 inbox_runner.py --once 今ある依頼を処理したら終わる python3 inbox_runner.py --check 設定・claude の場所と版を確かめるだけ 止め方: ターミナルで Ctrl+C。 一時停止は 依頼箱/ に「止める」という名前のファイルを置く(消すと再開)。 """ import argparse import datetime as dt import fcntl import json import os import re import shutil import signal import subprocess import sys import tempfile import time from pathlib import Path HERE = Path(__file__).resolve().parent CONFIG = HERE / "config.json" SETTINGS = HERE / "settings.json" FOLDERS = ["新規", "確認待ち", "実行中", "済み", "失敗", "結果"] PREAMBLE = """これは「依頼箱」から自動で渡された依頼です。人はこの画面を見ていません。質問はできません。 守ること: - 依頼の範囲だけを行う。範囲の外は変えない。 - エイイチが決めること(方針・数値の決め方・公開・実機を動かすこと)に当たったら、そこで手を止め、最後の報告の「決めてほしいこと」に書く。推測で決めて進めない。 - プロジェクトの決まり(MASTER・CLAUDE.md)に従う。git は個別にファイルを指定して add する。git clean は使わない。 - 依頼文で報告ファイルの置き場所が指定されていれば、そこにも書く。 - 最後の返答は日本語の報告にする。英語の文を混ぜない。形は次のとおり: やったこと/結果(数字があれば数字)/変えたファイル/決めてほしいこと(なければ「なし」) 依頼ファイル: {name} ----- ここから依頼 ----- """ def now(): return dt.datetime.now() def stamp(): return now().strftime("%Y%m%d_%H%M") def load_config(): with open(CONFIG, encoding="utf-8") as f: cfg = json.load(f) cfg["inbox"] = Path(os.path.expanduser(cfg["inbox"])) cfg["allowed_dirs"] = [Path(os.path.expanduser(p)).resolve() for p in cfg["allowed_dirs"]] return cfg def log(cfg, msg): line = f"{now().isoformat(timespec='seconds')} {msg}" print(line, flush=True) try: with open(cfg["inbox"] / "記録.log", "a", encoding="utf-8") as f: f.write(line + "\n") except OSError as e: print(f"記録.log に書けません: {e}", flush=True) def notify(title, msg): """Mac の通知を出す(失敗しても止めない)""" if sys.platform != "darwin": return msg = msg.replace("\n", " ") script = (f"display notification {json.dumps(msg, ensure_ascii=False)} " f"with title {json.dumps(title, ensure_ascii=False)}") try: subprocess.run(["osascript", "-e", script], capture_output=True, timeout=10) except Exception: pass HEADER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n", re.S) def parse_request(text): """頭の --- で囲んだ部分を読む。例: 作業フォルダ: /Users/... 上限時間: 120""" text = text.lstrip("").replace("\r\n", "\n") meta, body = {}, text m = HEADER_RE.match(text) if m: for line in m.group(1).splitlines(): parts = re.split(r"[::]", line, maxsplit=1) if len(parts) == 2: meta[parts[0].strip()] = parts[1].strip() body = text[m.end():] return meta, body def is_allowed(cfg, workdir): for base in cfg["allowed_dirs"]: if workdir == base or base in workdir.parents: return True return False def pending(cfg): d = cfg["inbox"] / "新規" items = [] for p in sorted(d.iterdir()): n = p.name if n.startswith(".") and n.endswith(".icloud"): # iCloud に退避されたファイルは取り寄せてから次の回で拾う real = d / n[1:-len(".icloud")] subprocess.run(["brctl", "download", str(real)], capture_output=True) continue if n.startswith(".") or not n.endswith(".md"): continue if time.time() - p.stat().st_mtime < cfg.get("settle_seconds", 15): continue # 書き込み中かもしれない items.append(p) return items def move(src, folder): dst = src.parent.parent / folder / src.name if dst.exists(): dst = dst.with_name(f"{dst.stem}_{stamp()}{dst.suffix}") shutil.move(str(src), str(dst)) return dst def child_env(cfg): """Claude Code に渡す環境。APIキーを外してプランの利用枠で動かす(config.json の drop_env / set_env)""" env = dict(os.environ) for k in cfg.get("drop_env", []): env.pop(k, None) env.update({k: str(v) for k, v in cfg.get("set_env", {}).items()}) return env def run_claude(cfg, prompt, workdir, limit_min): cmd = [cfg.get("claude", "claude"), "-p", prompt, "--output-format", "json", "--permission-mode", cfg["permission_mode"], "--permission-prompts", "none", "--settings", str(SETTINGS)] + list(cfg.get("extra_args", [])) # 出力はファイルで受ける(Claude Code が残した子プロセスが管をふさいで待ち続けるのを防ぐ) with tempfile.TemporaryFile("w+", encoding="utf-8") as fo, \ tempfile.TemporaryFile("w+", encoding="utf-8") as fe: p = subprocess.Popen(cmd, cwd=str(workdir), stdout=fo, stderr=fe, start_new_session=True, env=child_env(cfg)) timed_out = False try: p.wait(timeout=limit_min * 60) except subprocess.TimeoutExpired: timed_out = True # 先に SIGINT(その回の作業を終わらせる)→ だめなら SIGTERM → 最後に SIGKILL for sig, wait in ((signal.SIGINT, 120), (signal.SIGTERM, 60), (signal.SIGKILL, 30)): try: os.killpg(p.pid, sig) except ProcessLookupError: pass try: p.wait(timeout=wait) break except subprocess.TimeoutExpired: continue fo.seek(0) fe.seek(0) return p.returncode, fo.read(), fe.read(), timed_out def write_report(cfg, req, meta, workdir, started, rc, out, err, timed_out, note=""): res_dir = cfg["inbox"] / "結果" base = f"{req.stem}_{started.strftime('%Y%m%d_%H%M')}" data = None try: data = json.loads(out) if out.strip() else None except json.JSONDecodeError: data = None if out.strip(): (res_dir / f"{base}.json").write_text(out, encoding="utf-8") if err.strip(): (res_dir / f"{base}_stderr.txt").write_text(err, encoding="utf-8") is_error = bool(data.get("is_error")) if isinstance(data, dict) else True if timed_out: state = "時間切れ(途中で止めました)" elif note: state = "失敗" elif rc == 0 and not is_error: state = "完了" else: state = "失敗" ended = now() mins = (ended - started).total_seconds() / 60 lines = [f"# 結果:{req.stem}", "", f"- 状態:{state}", f"- 始めた時刻:{started.strftime('%Y-%m-%d %H:%M')}", f"- 終わった時刻:{ended.strftime('%Y-%m-%d %H:%M')}({mins:.0f}分)", f"- 作業フォルダ:{workdir}"] if isinstance(data, dict) and data.get("session_id"): lines.append(f"- この続きを Claude Code で話すとき:作業フォルダで `claude --resume {data['session_id']}`") denials = data.get("permission_denials") if isinstance(data, dict) else None if denials: lines.append(f"- 止められた操作:{len(denials)}件(いちばん下に一覧)") if "ANTHROPIC_API_KEY" in err or "auth source is set" in err: lines.append("- ★ APIキーで動いた可能性があります(プランの利用枠ではなく従量課金)。_stderr.txt を見てください") lines += ["", "## Claude Code の報告", ""] if note: lines.append(note) elif isinstance(data, dict) and data.get("result"): lines.append(str(data["result"])) else: lines.append("(報告がありません。終了コード " + str(rc) + "。_stderr.txt を見てください。ログインが切れているときは、ターミナルで claude を開いて /login)") if denials: lines += ["", "## 止められた操作", ""] for d in denials: lines.append("- " + json.dumps(d, ensure_ascii=False)[:500]) path = res_dir / f"{base}.md" path.write_text("\n".join(lines) + "\n", encoding="utf-8") return state, path def handle(cfg, req): started = now() req = move(req, "実行中") log(cfg, f"開始 {req.name}") meta, body = parse_request(req.read_text(encoding="utf-8")) wd_text = meta.get("作業フォルダ", "") workdir = Path(os.path.expanduser(wd_text)).resolve() if wd_text else None note = "" if not workdir: note = "依頼の頭に「作業フォルダ:」がありません。実行していません。" elif not workdir.is_dir(): note = f"作業フォルダが見つかりません:{workdir}。実行していません。" elif not is_allowed(cfg, workdir): note = (f"作業フォルダ {workdir} は自動で回してよい場所(config.json の allowed_dirs)に入っていません。" "実行していません。手で頼むか、allowed_dirs に足すかを決めてください。") try: limit = int(re.sub(r"[^0-9]", "", meta.get("上限時間", "")) or cfg["default_limit_minutes"]) except ValueError: limit = cfg["default_limit_minutes"] if note: rc, out, err, timed_out = -1, "", "", False else: prompt = PREAMBLE.format(name=req.name) + body try: rc, out, err, timed_out = run_claude(cfg, prompt, workdir, limit) except FileNotFoundError: rc, out, err, timed_out = -1, "", "", False note = "claude が見つかりません。config.json の claude に絶対パスを書いてください。" state, report = write_report(cfg, req, meta, workdir, started, rc, out, err, timed_out, note) move(req, "済み" if state == "完了" else "失敗") log(cfg, f"{state} {req.name} → 結果/{report.name}") notify("依頼箱", f"{state}:{req.stem}") def check(cfg): ok = True print("依頼箱:", cfg["inbox"], "あり" if cfg["inbox"].is_dir() else "★ ない") ok &= cfg["inbox"].is_dir() for d in cfg["allowed_dirs"]: print("自動で回してよい場所:", d, "あり" if d.is_dir() else "★ ない") exe = shutil.which(cfg.get("claude", "claude")) print("claude:", exe or "★ 見つからない") if exe: v = subprocess.run([exe, "--version"], capture_output=True, text=True) print("claude の版:", (v.stdout or v.stderr).strip()) else: ok = False print("承認の扱い:", cfg["permission_mode"], "/ 拒否の決まり:", SETTINGS) print("外す環境変数:", ", ".join(cfg.get("drop_env", [])) or "なし", "/ うち今この窓で設定されているもの:", ", ".join(k for k in cfg.get("drop_env", []) if k in os.environ) or "なし") return ok def main(): ap = argparse.ArgumentParser() ap.add_argument("--once", action="store_true") ap.add_argument("--check", action="store_true") a = ap.parse_args() cfg = load_config() if a.check: sys.exit(0 if check(cfg) else 1) for f in FOLDERS: (cfg["inbox"] / f).mkdir(parents=True, exist_ok=True) lock = open(HERE / ".runner.lock", "w") try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: print("見張り係はもう動いています(2つ目は起動しません)") sys.exit(1) if sys.platform == "darwin" and not a.once: # 見張っている間は Mac をスリープさせない(この係が終わると解除) subprocess.Popen(["caffeinate", "-i", "-w", str(os.getpid())]) log(cfg, "見張り係を起動" + ("(1回だけ)" if a.once else "")) # 前回、作業の途中で止まった依頼(Mac の再起動など)は「失敗」へ回す。 # 途中まで変わったファイルがあるかもしれないので、自動ではやり直さない。 for stale in sorted((cfg["inbox"] / "実行中").glob("*.md")): write_report(cfg, stale, {}, "不明", now(), -1, "", "", False, note="見張り係が止まった(Mac の再起動など)ため、この依頼は途中で終わっています。" "途中まで変わったファイルがあるかもしれません。やり直すかどうかは、確かめてから決めます。") move(stale, "失敗") log(cfg, f"中断されていた依頼を失敗へ {stale.name}") notify("依頼箱", f"中断されていた依頼があります:{stale.stem}") paused_logged = False try: while True: if (cfg["inbox"] / "止める").exists(): if not paused_logged: log(cfg, "「止める」があるので一時停止中") paused_logged = True else: paused_logged = False for req in pending(cfg): if (cfg["inbox"] / "止める").exists(): break try: handle(cfg, req) except Exception as e: # 1件の失敗で見張りを止めない log(cfg, f"★ {req.name} の処理中に失敗: {e!r}") if a.once: break time.sleep(cfg.get("poll_seconds", 30)) except KeyboardInterrupt: log(cfg, "見張り係を止めました") if __name__ == "__main__": main()