From 76f4ab4f7b4d0282816091d9c54bdf8828dfb4ac Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 13:41:01 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=8C=BA=E9=96=93=E3=81=AE?= =?UTF-8?q?=E5=88=87=E3=82=8C=E7=9B=AE=E3=81=AE=E5=86=8D=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E3=81=A8=E6=AC=A1=E3=81=AE=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=81=AE=E5=85=A5=E5=8A=9B=E3=82=92=E5=89=8D=E6=99=AF=E3=81=AE?= =?UTF-8?q?=E4=B8=AD=E7=B6=99=E3=81=A7=E8=87=AA=E5=8B=95=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=EF=BC=88#895=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - relay.py を新設(run / stop / mark / install)。claude を擬似端末の子として起動し、 Stop hook が写した ndf-next のブロックを受けて /exit・プラグインの更新・次の区間の起動を行う - 中継が要らない起動(-p・副命令・パイプ・中継の下)は本物の claude を exec して素通しする - SessionStart で中継を安定した場所へ置き直し、bash / zsh の設定へ alias を 1 度だけ足す - 文脈量の hook は中継の直接の子の conductor で 1 度の通しをやめ、理由を ndf-next の形にする - context-window.md に ndf-next のブロックの形を定め、relay.md を新設する - 実装で決めた 5 件を決定の記録へ追記する Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- issues/issue-895-design-decisions.md | 46 + issues/issue-895-design.md | 2 +- issues/issue-895-plan.md | 140 +++ plugins/ndf/README.md | 8 +- plugins/ndf/hooks/claude.json | 15 + plugins/ndf/scripts/relay.py | 1034 +++++++++++++++++ .../tests/fixtures/relay_fake_claude.py | 119 ++ plugins/ndf/scripts/tests/test_relay.py | 1018 ++++++++++++++++ plugins/ndf/scripts/tests/test_token_guard.py | 57 + plugins/ndf/scripts/token-guard.sh | 15 +- .../ndf/skills/development-workflow/SKILL.md | 8 +- .../references/context-window.md | 32 +- .../development-workflow/references/relay.md | 110 ++ 13 files changed, 2588 insertions(+), 16 deletions(-) create mode 100644 issues/issue-895-plan.md create mode 100755 plugins/ndf/scripts/relay.py create mode 100755 plugins/ndf/scripts/tests/fixtures/relay_fake_claude.py create mode 100644 plugins/ndf/scripts/tests/test_relay.py create mode 100644 plugins/ndf/skills/development-workflow/references/relay.md diff --git a/issues/issue-895-design-decisions.md b/issues/issue-895-design-decisions.md index 2b1e0fcc2..c9c2855f2 100644 --- a/issues/issue-895-design-decisions.md +++ b/issues/issue-895-design-decisions.md @@ -212,3 +212,49 @@ SessionStart hook は起動した版のパスで動くので、`claude plugin up Stop hook で上限を見て応答を続けさせる形は採らない。関門を `AskUserQuestion` でなく文で尋ねて止まった 応答まで、承認の前に切らせることになる。 + +## 実装で決めたこと(2026-09-23、Claude Code 2.1.280・`--model haiku` で実測) + +設計の「未確認のまま残ること」のうち、実装で決めるとした 3 件を実測で決めた。 + +### 決定 21: 次の区間の中身は `/goal` を含めたまま位置引数 1 つで渡す + +擬似端末の子として `claude --model haiku "/goal <条件>"` を起動すると、目標が設定されて応答が +始まった。1 行目が `/goal ...` の複数行の位置引数でも、改行ごと 1 つの条件として設定された。 +そのため設計どおり `<本物の claude> <印の中身>` で起動する。`/goal` を 2 つ目の入力として子の端末へ +書き込む代わりの形は作らない。 + +### 決定 22: 背景の処理の判定は `background_tasks` の `status: running` だけで行う + +背景で起動したサブエージェントは、主会話の Stop hook の `background_tasks` に +`{"id", "type": "subagent", "status": "running", "description", "agent_type"}` の形で載った。 +サブエージェントが終わると、人の入力なしに Stop がもう一度起き、`background_tasks` は空の配列に +戻った。背景の Bash(`type: shell`)と同じ判定で足りるため、`type` を問わず `running` の有無だけを +見る。会話の記録から背景の Agent の未完了を数える代わりの形は作らない。 + +### 決定 23: 目標の有無と判定は、記録の `goal_status` の attachment の行で読む + +`/goal` の目標の設定と判定は、会話の記録の `type: "attachment"` の行の +`attachment.type: "goal_status"` に書かれた。設定は `sentinel: true` と `met: false`、判定は +`met`(未達で止めを拒んだら `false` と `reason`、達成なら `true` と `reason`・`iterations` など)を持つ。 +`stop_hook_summary` の `preventedContinuation` は未達でも `false` のままで、拒否の検出には使えない。 + +中継は、最後の `sentinel` の行より後に目標が続いていて(`met: true` の判定が無い)、印の +`written_at` より後の判定の行(`timestamp`)が無ければ、`/exit` を入力せずに待つ。`met: true` の +判定の後は目標が終わったものとして待たない。 + +### 決定 24: 文脈量の hook は `relay.py is-child` で中継の直接の子かを見る + +文脈量の hook(`token-guard.sh`)は bash で書かれていて、`mark` の判定 4 と同じ親のたどりを持たない。 +同じ判定を bash に写すと、2 つの実装が食い違う。そこで `relay.py` に内部の副命令 `is-child` +(中継が動いていて、hook を呼んだ claude が `child.pid` と一致すれば終了コード 0)を置き、hook は +上限を超えたときだけそれを呼ぶ。利用者が打つ副命令ではないため、`relay.md` には載せない。 + +### 決定 25: 待ちの秒数は環境変数で短くできるようにし、試験で使う + +静まり(`NDF_RELAY_QUIET`)のほかに、印の確認の間隔(`NDF_RELAY_POLL`、既定 2)・`/exit` と改行の +間(`NDF_RELAY_EXIT_GAP`、既定 1)・`/exit` の後の待ち(`NDF_RELAY_EXIT_WAIT`、既定 30)・SIGTERM の +後の待ち(`NDF_RELAY_TERM_WAIT`、既定 10)・空回りの秒数(`NDF_RELAY_SPIN`、既定 120)・ +`plugin list` と `plugin update` の打ち切り(`NDF_RELAY_LIST_TIMEOUT` / `NDF_RELAY_UPDATE_TIMEOUT`)を +環境変数で受ける。既定の値は設計のとおりで、試験では 1 秒未満へ縮めて擬似端末の上の単体テストを +数十秒で終える。 diff --git a/issues/issue-895-design.md b/issues/issue-895-design.md index bb338bfd3..fe34a03bd 100644 --- a/issues/issue-895-design.md +++ b/issues/issue-895-design.md @@ -395,7 +395,7 @@ stateDiagram-v2 ## 決定の記録 -[issue-895-design-decisions.md](issue-895-design-decisions.md) にある(決定 20 件)。 +[issue-895-design-decisions.md](issue-895-design-decisions.md) にある(設計の決定 20 件と、実装で決めた 5 件)。 ## テスト設計 diff --git a/issues/issue-895-plan.md b/issues/issue-895-plan.md new file mode 100644 index 000000000..4f965bc1c --- /dev/null +++ b/issues/issue-895-plan.md @@ -0,0 +1,140 @@ +# #895: 区間の切れ目の再起動と次のコマンドの入力を、前景の中継で自動にする — 実装計画 + +## 関連リンク + +- 課題: https://github.com/devbasex/ai-plugins/issues/895 +- 設計 Pull Request: #908(2026-09-23 マージ、`2cab7076`) +- 要求と受け入れ条件: [issue-895-requirements.md](issue-895-requirements.md)(AC1〜AC26) +- 設計: [issue-895-design.md](issue-895-design.md) +- 決定の記録: [issue-895-design-decisions.md](issue-895-design-decisions.md)(決定 1〜20。実装で決めた 3 件を追記する) + +## モード + +standard(新しいスクリプトと hook の追加・利用者のシェル設定へ書く処理を含み、設計の関門を通した)。 + +## 目的と非目的 + +達成したい状態: + +- 設計どおりに `relay.py`(`run` / `stop` / `mark` / `install`)・hook の登録・文脈量の hook の強化・文書の規約を実装し、AC1〜AC26 を満たす + +やらないこと: + +- Codex / Kiro / agy の hook の変更(AC16・AC22) +- `waiting.md` と `agent-layers.md` の変更(#892 #901 の実装が触っている) +- #827 の supervisor の駆動の先取り +- `.md` の文言を固定するテスト(利用者の方針。AC1・AC2 は読んで確かめる) + +## 前提 + +- 前提 1: テストで利用者のシェル設定を書き換える操作は、すべて一時の HOME(`tmp_path`)で行い、本物の `~/.bashrc` / `~/.zshrc` / `~/.local` を変えない +- 前提 2: 中継の単体テストは、子の claude を擬似端末の上で動く短い試験用の Python プログラムに差し替え、`claude plugin ...` も差し替えの実行ファイル(`NDF_RELAY_CLAUDE` で指す 1 本が `plugin` の副命令と区間の起動の両方を受ける)で行う +- 前提 3: 未確認のうち実装で決める 3 件(`/goal` を位置引数で渡せるか・背景のサブエージェントが `background_tasks` に載るか・`goal_status` の記録の形)は実測(worker の調査)で決め、決定の記録へ追記する + +## 受け入れ条件 + +要求の文書の AC1〜AC26 をそのまま使う。検証手段は要求の文書の「検証手段」と設計の「テスト設計」の表のとおり。 + +## 代替案と採否 + +設計の決定 1〜20 で決めたとおり。実装の中で決めるのは次の 3 件(実測の結果で埋める)。 + +| 項目 | 既定の案 | 替える条件 | +| --- | --- | --- | +| `/goal` を含む中身の渡し方 | 位置引数 1 つで渡す | 位置引数の `/goal` が働かなければ、`/goal` を外した中身で起動し、`/goal ...` を 2 つ目の入力として子の端末へ書く | +| 背景のサブエージェントの見分け | `background_tasks` の `status: running` | サブエージェントが載らなければ、会話の記録から背景の Agent の未完了を数える | +| 目標のある区間の判定の記録 | 記録の `goal_status` の行(印の `written_at` より後) | 実測した行の形に合わせる | + +## 修正対象 + +| パス | 区分 | +| --- | --- | +| `plugins/ndf/scripts/relay.py` | 新設 | +| `plugins/ndf/scripts/tests/test_relay.py` | 新設 | +| `plugins/ndf/hooks/claude.json` | Stop に `mark`、SessionStart(`startup`)に `install` を 1 件ずつ足す | +| `plugins/ndf/scripts/token-guard.sh` | 理由の文を `ndf-next` の形へ。中継の直接の子の conductor では 1 度の通しをやめる | +| `plugins/ndf/scripts/tests/test_token_guard.py` | AC23 のテストを足す | +| `plugins/ndf/skills/development-workflow/references/context-window.md` | 「新しい会話で戻す」に `ndf-next` の形を定める | +| `plugins/ndf/skills/development-workflow/references/relay.md` | 新設(始め方・止め方・上限・記録の読み方・落ちたときの続け方) | +| `plugins/ndf/skills/development-workflow/SKILL.md` | 引き継ぎの段落と「`/goal` の引数として呼ばれたとき」 | +| `plugins/ndf/README.md` | hook の一覧に中継の印と `install` を足す | +| `issues/issue-895-design-decisions.md` | 実装で決めた 3 件を追記する | + +## タスク分解 + +各タスクは失敗するテスト → 通す最小実装 → 整理で進める(`tdd-cycle`)。文書のタスク(Task 8)だけはテスト駆動を適用しない(文言を固定するテストを書かない方針のため)。 + +### Task 1: `mark`(印を書く・消す) + +- **対象:** `relay.py` の `mark`・ブロックの読み取り・親のたどり、`test_relay.py` +- **変更内容:** 設計の「`mark` の判定」1〜6。外側の囲みの中を除くブロックの数え方。`background_tasks` の `running` で書かない。親のたどりは `/proc//stat`(macOS は `ps`)で、テストでは環境変数 `NDF_RELAY_TEST_CLAUDE_PID` ではなく関数の差し替え(モジュールを読み込んで呼ぶ)で与える +- **満たす受け入れ条件:** AC3・AC4・AC4b・AC24・AC16(hook 側) + +### Task 2: `run` の素通しと本物の claude の解決 + +- **対象:** `relay.py` の `run` の条件 1〜6、`resolve_claude` +- **変更内容:** 素通しの 6 条件、`NDF_RELAY_DEPTH`、ラッパーを飛ばす探索、`NDF_RELAY_CLAUDE`、`plugin list --json` の読み取りと打ち切り +- **満たす受け入れ条件:** AC15・AC20 + +### Task 3: `run` の中継(1 区間) + +- **対象:** `relay.py` の擬似端末の中継・作業ディレクトリ・記録 +- **変更内容:** `NDF_RELAY_DIR` の作成(`0700`、起動ごとに新しく)・`relay.lock` / `relay.pid` / `child.pid`、同期のパイプと結果のパイプによる起動、入出力と SIGWINCH の中継、端末の属性の保存と復元、印なしの終わりで子の終了コードを返す +- **満たす受け入れ条件:** AC7・AC12・AC19・AC26 + +### Task 4: 切れ目の切り替え(`/exit` → 更新 → 次の区間) + +- **対象:** `relay.py` の静まりの待ち・終わらせる・起動する +- **変更内容:** 静まり(印・記録・入力・目標の判定の記録)、`/exit` と `\r`、30 秒で SIGTERM・10 秒で SIGKILL、`marketplace update` / `plugin update -y` / `plugin list --json`、区切りの 1 行、`cwd` の落とし先、`start` / `end` の記録 +- **満たす受け入れ条件:** AC6・AC8・AC9 + +### Task 5: 上限と止め方 + +- **対象:** `relay.py` の `stop`・1 日の起動回数(`count.lock`)・空回り・停止の印、落ちるときの 1 行と `stop` の行 +- **満たす受け入れ条件:** AC10・AC11・AC13・AC14 + +### Task 6: `install` + +- **対象:** `relay.py` の `install` +- **変更内容:** I1〜I7(`install.lock`・安定した場所への写し・bash / zsh の設定への囲み・`rc-added` / `rc-skipped`・バックアップ・`systemMessage`)。テストは一時の HOME だけで行う +- **満たす受け入れ条件:** AC21 + +### Task 7: hook の登録と文脈量の hook + +- **対象:** `hooks/claude.json`、`token-guard.sh`、`test_token_guard.py` +- **変更内容:** Stop と SessionStart へ 1 件ずつ。文脈量の hook の理由の文と、中継の直接の子の conductor では通しをやめる判定(親のたどりは `relay.py` の関数を呼ぶ) +- **満たす受け入れ条件:** AC22・AC23 + +### Task 8: 文書 + +- **対象:** `context-window.md`・`relay.md`・`SKILL.md`・`README.md`・決定の記録 +- **満たす受け入れ条件:** AC1・AC2(読んで確かめる) +- **進め方:** テスト駆動を適用しない(文言を固定するテストは書かない方針) + +### Task 9: 通しの確かめと全体テスト + +- **対象:** 擬似端末の上で本物の claude(`--model haiku`)を使う通しの確かめ(AC17・AC25・AC5)と全体テスト(AC18)。記録は実装の Pull Request に残す + +## 影響範囲 + +- ndf を入れた Claude Code の利用者(bash / zsh)は、次に開くシェルから `claude` が中継を挟む。非対話・副命令・`-p` は素通しで変わらない +- 文脈量の hook の理由の文が変わる(中継の外の振る舞いは同じ) + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 擬似端末のテストが遅い・不安定になる | 待ちの秒数(静まり・打ち切り・終わらせる待ち)を環境変数で短く差し替えられるようにし、テストでは 1 秒未満にする。タスクごとにテストを通す | +| 利用者の本物のシェル設定を書き換える | `install` のテストは必ず一時の HOME と `XDG_*` を与え、テストの補助関数で本物の HOME を指していないことを確かめる | +| 触る既存のファイル(`token-guard.sh`)の構造 | 変更は文脈量の判定の関数の中に閉じる。実装の後の構造改善で足りる | + +## 切り戻し手順 + +- 利用者の側: `NDF_RELAY_AUTO=0` で `install` を止め、`~/.bashrc` の囲みを消す(バックアップ `<設定>.ndf-bak-<時刻>` もある)。`NDF_RELAY=0` で中継を常に素通しにする +- リポジトリ: この Pull Request を revert すれば元に戻る(データの移行は無い) + +## 完了の定義 + +- [ ] AC1〜AC26 をすべて満たし、条件ごとに検証手段と結果が Pull Request に対応している +- [ ] `test_relay.py` と `test_token_guard.py` が通り、全体テスト(`-n 4`)が通る +- [ ] `claude plugin validate .` が終了コード 0 diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index 283ba28bc..26934d168 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -232,7 +232,7 @@ bash <プラグインのパス>/scripts/worktree-setup.sh init | --- | --- | --- | | 前景の `sleep` の待ち(`while` / `until` のループの本体、または上限を超える秒数) | `NDF_SLEEP_GUARD=0` | `NDF_SLEEP_MAX_SEC`(既定 5) | | 変わらないファイルの同じ範囲を続けて読む Read | `NDF_READ_REPEAT_GUARD=0` | `NDF_READ_REPEAT_LIMIT`(既定 3) | -| 文脈が上限を超えた conductor が工程へ入る起動(1 度だけ止め、新しい会話で打つ 1 行を示す) | `NDF_CONTEXT_GUARD=0` | `NDF_CONTEXT_LIMIT`(既定 200000) | +| 文脈が上限を超えた conductor が工程へ入る起動(1 度だけ止め、新しい会話で打つコマンドを `ndf-next` のブロックで示させる。中継の下では止め続ける) | `NDF_CONTEXT_GUARD=0` | `NDF_CONTEXT_LIMIT`(既定 200000) | | ランタイム | 待ち方 | 会話を切る | | --- | --- | --- | @@ -250,9 +250,13 @@ Claude Code の SessionStart hook(`hooks/claude.json`)は上記に加えて - `~/.claude/settings.json` の `cleanupPeriodDays` を 90 日以上に保つ - statusline 未設定時に NDF 標準 statusline を設定する +- 区間の切れ目の中継(`scripts/relay.py`)を安定した場所へ置き直し、bash / zsh の設定へ + `alias claude=...` を印のついた囲みで 1 度だけ足す(`relay.py install`。`NDF_RELAY_AUTO=0` で止まる) Claude Code の Stop hook は終了時に Slack 通知スクリプトを実行します。通知に必要な環境変数が -未設定の場合は送信せず終了します。 +未設定の場合は送信せず終了します。中継の下(`NDF_RELAY_DIR` がある)では、最後の応答の +`ndf-next` のブロックを中継の印へ写します(`relay.py mark`)。中継の始め方・止め方・上限は +`skills/development-workflow/references/relay.md` にあります。 Codex の Stop hook(`hooks/codex.json`)は `NDF_CODEX_SLACK_NOTIFY=true` が設定されている 場合だけ Slack 通知を送ります。**Codex の hook は Codex 側で明示的に有効化するまで実行され diff --git a/plugins/ndf/hooks/claude.json b/plugins/ndf/hooks/claude.json index eb379eb4b..c66207097 100644 --- a/plugins/ndf/hooks/claude.json +++ b/plugins/ndf/hooks/claude.json @@ -52,6 +52,14 @@ "description": "NDF: report stray changes in the main directory and follow the active worktree branch", "continueOnError": true, "suppressOutput": false + }, + { + "type": "command", + "command": "python3 ${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/scripts/relay.py install", + "description": "NDF: place the relay in a stable location and add the claude alias to the shell rc once", + "timeout": 5, + "continueOnError": true, + "suppressOutput": false } ] } @@ -64,6 +72,13 @@ "command": "sh -c 'ROOT=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; if [ -n \"$ROOT\" ]; then node \"$ROOT/scripts/slack-notify.js\" session_end; fi; exit 0'", "description": "Send Slack notification when Claude Code exits", "continueOnError": true + }, + { + "type": "command", + "command": "sh -c '[ -n \"${NDF_RELAY_DIR:-}\" ] || exit 0; ROOT=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; [ -n \"$ROOT\" ] || exit 0; python3 \"$ROOT/scripts/relay.py\" mark; exit 0'", + "description": "NDF: under the relay, copy the ndf-next block of the last reply to the relay mark", + "timeout": 5, + "continueOnError": true } ] } diff --git a/plugins/ndf/scripts/relay.py b/plugins/ndf/scripts/relay.py new file mode 100755 index 000000000..eb8ffa1c9 --- /dev/null +++ b/plugins/ndf/scripts/relay.py @@ -0,0 +1,1034 @@ +#!/usr/bin/env python3 +"""NDF の中継: 区間の切れ目で claude を起動し直す(#895)。 + +副命令: + +| 副命令 | 役割 | +| --- | --- | +| `run [claude の引数 ...]` | 端末の前景に常駐し、claude を擬似端末の子として起動する。印を受けたら子へ `/exit` を入力し、プラグインを更新して次の区間を起動する。中継が要らない起動は本物の claude をそのまま exec する(素通し) | +| `stop` | 動いている中継すべてに停止の印を置く | +| `mark` | Stop hook の本体。最後の応答の `ndf-next` のブロックを印 `next.json` へ写す | +| `install` | SessionStart hook の本体。中継を安定した場所へ置き直し、bash / zsh の設定へ alias を 1 度だけ足す | + +**標準ライブラリだけで書く。** 印と作業ディレクトリの形(`next.json` のキーと +`NDF_RELAY_DIR` のファイル)は版をまたいで変えない。hook は区間ごとに新しい版で動き、 +動いている中継は古い版のままでありうるためである。 + +規約は skills/development-workflow/references/relay.md にある。 +""" +from __future__ import annotations + +import datetime as _dt +import errno +import fcntl +import json +import os +import random +import re +import select +import shlex +import signal +import subprocess +import sys +import time + +MARK_FILE = "next.json" +STOP_FILE = "stop" +LOCK_FILE = "relay.lock" +PID_FILE = "relay.pid" +CHILD_FILE = "child.pid" +LOG_FILE = "log.jsonl" +COUNT_LOCK = "count.lock" +INSTALL_LOCK = "install.lock" + +BLOCK_OPEN = "# >>> ndf relay >>>" +BLOCK_CLOSE = "# <<< ndf relay <<<" +ALIAS_LINE = """alias claude='python3 "${XDG_DATA_HOME:-$HOME/.local/share}/ndf/relay.py" run'""" +RC_BLOCK = (f"{BLOCK_OPEN}\n" + "# ndf の中継(区間の切れ目で claude を自動で起動し直す)。消せば元に戻る。\n" + f"{ALIAS_LINE}\n" + f"{BLOCK_CLOSE}\n") + +# 素通しにする引数と副命令(Claude Code 2.1.280 の `claude --help` から写す) +PASS_FLAGS = {"-p", "--print", "-h", "--help", "-v", "--version"} +SUBCOMMANDS = { + "agents", "attach", "auth", "auto-mode", "doctor", "gateway", "import", "install", + "logs", "mcp", "plugin", "plugins", "project", "respawn", "rm", "setup-token", + "stop", "kill", "ultrareview", "update", "upgrade", +} +# 子へ継がせない Claude Code の環境変数(中から起こしたプロセスが継ぐもの) +DROP_ENV = ("CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_ENTRYPOINT") + + +def _num(name: str, default: float) -> float: + try: + return float(os.environ.get(name, "") or default) + except ValueError: + return default + + +# ---------------------------------------------------------------- 共通 + + +def now_iso(t: float | None = None) -> str: + t = time.time() if t is None else t + d = _dt.datetime.fromtimestamp(t, _dt.timezone.utc) + return d.strftime("%Y-%m-%dT%H:%M:%S.") + f"{d.microsecond // 1000:03d}Z" + + +def parse_iso(s) -> float | None: + if not isinstance(s, str): + return None + try: + return _dt.datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%fZ").replace( + tzinfo=_dt.timezone.utc).timestamp() + except ValueError: + return None + + +def state_root() -> str: + base = os.environ.get("XDG_STATE_HOME") or os.path.join(os.path.expanduser("~"), ".local", "state") + return os.path.join(base, "ndf", "relay") + + +def data_dir() -> str: + base = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share") + return os.path.join(base, "ndf") + + +def relay_running(d: str) -> bool: + """`relay.lock` の排他が取れなければ中継が動いている。pid の生死では見ない。""" + try: + fd = os.open(os.path.join(d, LOCK_FILE), os.O_RDWR) + except OSError: + return False + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + return True + else: + fcntl.flock(fd, fcntl.LOCK_UN) + return False + finally: + os.close(fd) + + +def write_json_atomic(path: str, data) -> None: + tmp = f"{path}.{os.getpid()}.tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, path) + + +def read_json(path: str): + try: + with open(path) as f: + return json.load(f) + except (OSError, ValueError): + return None + + +def remove(path: str) -> None: + try: + os.unlink(path) + except OSError: + pass + + +# ---------------------------------------------------------------- 親のたどり + + +def proc_info(pid: int) -> tuple[int, str] | None: + """(親の pid, 名前)。Linux は /proc、それ以外は ps で読む。""" + try: + with open(f"/proc/{pid}/stat") as f: + s = f.read() + name = s[s.index("(") + 1:s.rindex(")")] + ppid = int(s[s.rindex(")") + 2:].split()[1]) + return ppid, name + except (OSError, ValueError, IndexError): + pass + try: + out = subprocess.run(["ps", "-o", "ppid=,comm=", "-p", str(pid)], capture_output=True, + text=True, timeout=2).stdout.strip() + ppid, name = out.split(None, 1) + return int(ppid), os.path.basename(name) + except (OSError, ValueError, subprocess.SubprocessError): + return None + + +def is_direct_child(d: str, start: int | None = None) -> bool: + """hook の親をたどって最初に当たる claude が、中継の起動した子(`child.pid`)かを見る。 + + claude は名前(`claude`)か、子と同じ名前か、pid そのもので見分ける。 + conductor が Bash から起こした `claude -p` は子の claude の孫に当たり、先にそちらに当たる。 + """ + try: + with open(os.path.join(d, CHILD_FILE)) as f: + child = int(f.read().strip()) + except (OSError, ValueError): + return False + info = proc_info(child) + child_name = info[1] if info else None + pid = os.getppid() if start is None else start + for _ in range(64): + if pid <= 1: + return False + if pid == child: + return True + info = proc_info(pid) + if info is None: + return False + ppid, name = info + if name == "claude" or (child_name is not None and name == child_name): + return False + pid = ppid + return False + + +# ---------------------------------------------------------------- mark + + +FENCE_RE = re.compile(r"^(`{3,})(.*)$") + + +def next_blocks(text: str) -> list[str]: + """外側の囲みの中を除き、情報文字列が `ndf-next` の 3 つのバッククォートの囲みの中身を返す。""" + blocks: list[str] = [] + open_len = 0 + cur: list[str] | None = None + for line in text.split("\n"): + m = FENCE_RE.match(line.rstrip("\r")) + if not open_len: + if m and "`" not in m.group(2): + open_len = len(m.group(1)) + cur = [] if open_len == 3 and m.group(2).strip() == "ndf-next" else None + continue + if m and len(m.group(1)) >= open_len and not m.group(2).strip(): + if cur is not None: + blocks.append("\n".join(cur).strip("\n")) + open_len, cur = 0, None + elif cur is not None: + cur.append(line) + return blocks + + +def background_running(tasks) -> bool: + if not isinstance(tasks, list): + return False + return any(isinstance(t, dict) and t.get("status") == "running" for t in tasks) + + +def cmd_mark() -> int: + d = os.environ.get("NDF_RELAY_DIR") + if not d or not relay_running(d): + return 0 + try: + data = json.loads(sys.stdin.read()) + except ValueError: + return 0 + if not isinstance(data, dict) or not is_direct_child(d): + return 0 + path = os.path.join(d, MARK_FILE) + blocks = next_blocks(str(data.get("last_assistant_message") or "")) + if len(blocks) != 1 or background_running(data.get("background_tasks")): + remove(path) + return 0 + write_json_atomic(path, { + "command": blocks[0], + "cwd": data.get("cwd") or "", + "session_id": data.get("session_id") or "", + "transcript_path": data.get("transcript_path") or "", + "written_at": now_iso(), + }) + return 0 + + +# ---------------------------------------------------------------- 本物の claude + + +def _is_self(path: str) -> bool: + real = os.path.realpath(path) + mine = {os.path.realpath(__file__), os.path.realpath(os.path.join(data_dir(), "relay.py"))} + if real in mine: + return True + try: + with open(real, "rb") as f: + return b"relay.py" in f.read(4096) + except OSError: + return True + + +def resolve_claude() -> str | None: + """alias は子に効かないため実体を探す。`claude` という名前で中継を呼ぶラッパーを飛ばす。""" + forced = os.environ.get("NDF_RELAY_CLAUDE") + if forced: + return forced + for d in os.environ.get("PATH", "").split(os.pathsep): + p = os.path.join(d or ".", "claude") + if os.path.isfile(p) and os.access(p, os.X_OK) and not _is_self(p): + return os.path.abspath(p) + return None + + +def needs_no_relay(args: list[str]) -> bool: + if any(a in PASS_FLAGS or a.startswith("--print=") for a in args): + return True + return bool(args) and args[0] in SUBCOMMANDS + + +def say(msg: str) -> None: + """`ndf-relay:` の 1 行を標準エラーへ出す。""" + sys.stderr.write(f"ndf-relay: {msg}\n") + sys.stderr.flush() + + +def depth() -> int: + try: + return int(os.environ.get("NDF_RELAY_DEPTH") or 0) + except ValueError: + return 0 + + +def passthrough(claude: str, args: list[str]): + """環境は入れ子を数える変数だけを足して、本物の claude を exec する。""" + env = dict(os.environ) + env["NDF_RELAY_DEPTH"] = str(depth() + 1) + os.execve(claude, [claude] + args, env) + + +def child_env(relay_dir: str) -> dict: + env = {k: v for k, v in os.environ.items() if k not in DROP_ENV} + env["NDF_RELAY_DEPTH"] = str(depth() + 1) + env["NDF_RELAY_DIR"] = relay_dir + return env + + +def plugin_cli(claude: str, args: list[str], timeout: float) -> subprocess.CompletedProcess | None: + env = {k: v for k, v in os.environ.items() if k not in DROP_ENV and k != "NDF_RELAY_DIR"} + try: + return subprocess.run([claude, "plugin"] + args, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=timeout, env=env) + except (OSError, subprocess.SubprocessError): + return None + + +def read_plugin(claude: str, marketplace: str | None = None) -> tuple[str, str] | None: + """`claude plugin list --json` から (マーケットプレイス, 版) を読む。""" + p = plugin_cli(claude, ["list", "--json"], _num("NDF_RELAY_LIST_TIMEOUT", 15)) + if p is None or p.returncode != 0: + return None + try: + items = json.loads(p.stdout) + except ValueError: + return None + for it in items if isinstance(items, list) else []: + pid = it.get("id") if isinstance(it, dict) else None + if not isinstance(pid, str) or not pid.startswith("ndf@"): + continue + mk = pid.split("@", 1)[1] + if marketplace is not None and mk != marketplace: + continue + ver = it.get("version") + if mk and isinstance(ver, str) and ver: + return mk, ver + return None + + +# ---------------------------------------------------------------- run + + +class StartFailed(Exception): + def __init__(self, err: int): + super().__init__(err) + self.err = err + + +def exit_code(status: int) -> int: + code = os.waitstatus_to_exitcode(status) + return 128 - code if code < 0 else code + + +def fallback_cwd(cwd: str) -> str: + """印の作業ディレクトリが消えていたら、主ディレクトリか在る最も近い親を返す。""" + if "/.worktrees/" in cwd: + main = cwd.split("/.worktrees/", 1)[0] + if os.path.isdir(main): + return main + p = cwd + while p and not os.path.isdir(p): + parent = os.path.dirname(p) + if parent == p: + break + p = parent + return p if p and os.path.isdir(p) else os.path.expanduser("~") + + +def make_relay_dir() -> str: + root = state_root() + os.makedirs(root, mode=0o700, exist_ok=True) + stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + for _ in range(20): + d = os.path.join(root, f"{stamp}-{os.getpid()}-{random.randint(0, 99999999):08d}") + try: + os.mkdir(d, 0o700) + return d + except FileExistsError: + continue + raise OSError(errno.EEXIST, "relay dir") + + +class Relay: + """前景に常駐し、区間ごとの claude を擬似端末の子として起動する。""" + + def __init__(self, claude: str, relay_dir: str, marketplace: str, version: str): + self.claude = claude + self.dir = relay_dir + self.marketplace = marketplace + self.version = version + self.env = child_env(relay_dir) + self.section = 0 + self.pid = 0 + self.fd = -1 + self.started_at = 0.0 + self.session_id = "" + self.halted = False + self.last_input = 0.0 + self.last_tick = 0.0 + self.stdin_open = True + self.count_lock: int | None = None + self.quiet = _num("NDF_RELAY_QUIET", 15) + self.poll = _num("NDF_RELAY_POLL", 2) + self.max_starts = int(_num("NDF_RELAY_MAX_STARTS", 20)) + self.spin = _num("NDF_RELAY_SPIN", 120) + self.lock_fd = os.open(self.path(LOCK_FILE), os.O_RDWR | os.O_CREAT, 0o600) + fcntl.flock(self.lock_fd, fcntl.LOCK_EX) + with open(self.path(PID_FILE), "w") as f: + f.write(str(os.getpid())) + + def path(self, name: str) -> str: + return os.path.join(self.dir, name) + + # -- 記録 + + def log(self, **row) -> None: + row = {"event": row.pop("event"), "at": row.pop("at", None) or now_iso(), **row} + with open(self.path(LOG_FILE), "a") as f: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + def screen(self, line: str) -> None: + """端末は raw なので行の頭へ戻してから書く。""" + try: + os.write(1, ("\r\n" + line + "\r\n").encode()) + except OSError: + pass + + # -- 子の起動 + + def spawn(self, args: list[str], cwd: str) -> float: + import pty + sync_r, sync_w = os.pipe() + res_r, res_w = os.pipe() + pid, fd = pty.fork() + if pid == 0: + try: + os.close(sync_w) + os.close(res_r) + os.read(sync_r, 1) + os.chdir(cwd) + os.execve(self.claude, [self.claude] + args, self.env) + except OSError as e: + os.write(res_w, str(e.errno or errno.EIO).encode()) + finally: + os._exit(127) + os.close(sync_r) + os.close(res_w) + self.copy_winsize(fd) + with open(self.path(CHILD_FILE), "w") as f: + f.write(str(pid)) + at = time.time() + os.close(sync_w) + data = b"" + while True: + try: + chunk = os.read(res_r, 64) + except InterruptedError: + continue + if not chunk: + break + data += chunk + os.close(res_r) + if data: + os.waitpid(pid, 0) + os.close(fd) + raise StartFailed(int(data or errno.EIO)) + self.pid, self.fd = pid, fd + return at + + def start_section(self, args: list[str], cwd: str, command: str, from_session: str, + cwd_fallback: str | None = None) -> None: + remove(self.path(MARK_FILE)) + at = self.spawn(args, cwd) + self.section += 1 + self.started_at = at + row = dict(event="start", at=now_iso(at), section=self.section, pid=self.pid, + command=command, from_session=from_session, + plugin_version=self.version, cwd=cwd) + if cwd_fallback is not None: + row["cwd_fallback"] = cwd_fallback + self.log(**row) + self.release_count() + + # -- 端末 + + def copy_winsize(self, fd: int | None = None) -> None: + import termios + fd = self.fd if fd is None else fd + if fd < 0: + return + try: + ws = fcntl.ioctl(0, termios.TIOCGWINSZ, b"\0" * 8) + fcntl.ioctl(fd, termios.TIOCSWINSZ, ws) + except OSError: + pass + + def pump(self, until: float | None = None, tick=None): + """入出力を中継する。子が終われば ("exit", status)、tick が値を返せば ("tick", 値)、 + 期限に達すれば None を返す。""" + master_open = True + while True: + try: + wpid, status = os.waitpid(self.pid, os.WNOHANG) + except ChildProcessError: + wpid, status = self.pid, 0 + if wpid: + self.drain() + os.close(self.fd) + self.fd = -1 + return ("exit", status) + if until is not None and time.time() >= until: + return None + fds = [self.fd] if master_open else [] + if self.stdin_open: + fds.append(0) + try: + r, _, _ = select.select(fds, [], [], 0.1) + except InterruptedError: + r = [] + if self.fd in r: + try: + data = os.read(self.fd, 65536) + except OSError: + data = b"" + if data: + self.write_out(data) + else: + master_open = False + if 0 in r: + try: + data = os.read(0, 4096) + except OSError: + data = b"" + if data: + self.last_input = time.time() + try: + os.write(self.fd, data) + except OSError: + pass + else: + self.stdin_open = False + if tick is not None and time.time() - self.last_tick >= self.poll: + self.last_tick = time.time() + v = tick() + if v is not None: + return ("tick", v) + + def drain(self) -> None: + while True: + try: + r, _, _ = select.select([self.fd], [], [], 0) + if not r: + return + data = os.read(self.fd, 65536) + except OSError: + return + if not data: + return + self.write_out(data) + + @staticmethod + def write_out(data: bytes) -> None: + while data: + try: + n = os.write(1, data) + except InterruptedError: + continue + except OSError: + return + data = data[n:] + + # -- 印の判定 + + def read_mark(self): + m = read_json(self.path(MARK_FILE)) + if not isinstance(m, dict) or not isinstance(m.get("command"), str) or not m["command"]: + return None + return m + + def tick(self): + if self.halted: + return None + try: + return self._tick() + except Exception as e: # 本体の例外で子を巻き込まない + self.halt("error", f"中継の中で例外が起きた({type(e).__name__})") + return None + + def _tick(self): + m = self.read_mark() + if m is None: + return None + written = parse_iso(m.get("written_at")) or time.time() + latest = max(written, self.last_input) + tp = m.get("transcript_path") or "" + try: + latest = max(latest, os.stat(tp).st_mtime) + except OSError: + pass + if time.time() - latest < self.quiet: + return None + if goal_pending(tp, written): + return None + if os.path.exists(self.path(STOP_FILE)): + self.halt("stop-file", "停止の印がある") + return None + if not self.take_count(): + return None + if self.count_today() >= self.max_starts: + self.release_count() + self.halt("max-starts", f"1 日の起動回数が上限 {self.max_starts} に達した") + return None + if self.spinning(written): + self.release_count() + self.halt("spin", f"区間が 3 つ続けて {int(self.spin)} 秒未満で切れ目に達した") + return None + return m + + def halt(self, reason: str, why: str) -> None: + self.halted = True + self.log(event="stop", section=self.section, reason=reason) + remove(self.path(MARK_FILE)) + self.screen(f"ndf-relay: 次の区間を起動しない({why})。このまま続けるか、" + "/exit して示されたコマンドを手で入力する") + + def take_count(self) -> bool: + if self.count_lock is not None: + return True + fd = os.open(os.path.join(state_root(), COUNT_LOCK), os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + os.close(fd) + return False + self.count_lock = fd + return True + + def release_count(self) -> None: + if self.count_lock is not None: + fcntl.flock(self.count_lock, fcntl.LOCK_UN) + os.close(self.count_lock) + self.count_lock = None + + def count_today(self) -> int: + today = time.localtime().tm_yday, time.localtime().tm_year + n = 0 + root = state_root() + for name in os.listdir(root): + try: + with open(os.path.join(root, name, LOG_FILE)) as f: + for line in f: + try: + row = json.loads(line) + except ValueError: + continue + if row.get("event") != "start": + continue + t = parse_iso(row.get("at")) + if t is not None: + lt = time.localtime(t) + n += (lt.tm_yday, lt.tm_year) == today + except OSError: + continue + return n + + def spinning(self, written: float) -> bool: + ends = [] + try: + with open(self.path(LOG_FILE)) as f: + for line in f: + try: + row = json.loads(line) + except ValueError: + continue + if row.get("event") == "end": + ends.append(row.get("seconds")) + except OSError: + return False + last = ends[-2:] + if len(last) < 2 or not all(isinstance(s, (int, float)) and s < self.spin for s in last): + return False + return written - self.started_at < self.spin + + # -- 切り替え + + def end_child(self) -> str: + """子へ `/exit` を入力して終わらせる。30 秒で SIGTERM、さらに 10 秒で SIGKILL。""" + waits = [("/exit", _num("NDF_RELAY_EXIT_GAP", 1)), ("\r", _num("NDF_RELAY_EXIT_WAIT", 30))] + for keys, wait in waits: + try: + os.write(self.fd, keys.encode()) + except OSError: + pass + if self.pump(until=time.time() + wait): + return "mark" + for sig, how, wait in ((signal.SIGTERM, "sigterm", _num("NDF_RELAY_TERM_WAIT", 10)), + (signal.SIGKILL, "sigkill", None)): + try: + os.kill(self.pid, sig) + except ProcessLookupError: + pass + if self.pump(until=None if wait is None else time.time() + wait): + return how + return "sigkill" + + def update(self) -> str | None: + t = _num("NDF_RELAY_UPDATE_TIMEOUT", 120) + for args in (["marketplace", "update", self.marketplace], + ["update", f"ndf@{self.marketplace}", "-y"]): + p = plugin_cli(self.claude, args, t) + if p is None or p.returncode != 0: + return None + got = read_plugin(self.claude, self.marketplace) + return got[1] if got else None + + def give_up(self, reason: str, why: str, command: str, **extra) -> int: + self.release_count() + self.log(event="stop", section=self.section, reason=reason, **extra) + self.screen(f"ndf-relay: 次の区間を起動できない({why})。次のコマンド:\r\n{command}") + return 2 + + def loop(self, first_args: list[str]) -> int: + try: + at = self.spawn(first_args, os.getcwd()) + except StartFailed as e: + self.log(event="stop", section=1, reason="start-failed", errno=e.err) + say(f"claude を起動できない({os.strerror(e.err)})") + return 127 + self.section = 1 + self.started_at = at + self.log(event="start", at=now_iso(at), section=1, pid=self.pid, + command=shlex.join(first_args), from_session="", + plugin_version=self.version, cwd=os.getcwd()) + while True: + res = self.pump(tick=self.tick) + if res[0] == "exit": + self.log(event="end", section=self.section, pid=self.pid, + seconds=round(time.time() - self.started_at, 3), ended_by="no-mark") + return exit_code(res[1]) + m = res[1] + written = parse_iso(m.get("written_at")) or time.time() + ended_by = self.end_child() + self.log(event="end", section=self.section, pid=self.pid, + seconds=round(written - self.started_at, 3), ended_by=ended_by) + command = m["command"] + version = self.update() + if version is None: + return self.give_up("update-failed", "プラグインの更新か版の読み取りに失敗した", command) + self.version = version + cwd = m.get("cwd") or os.getcwd() + fb = None + if not os.path.isdir(cwd): + fb, cwd = cwd, fallback_cwd(cwd) + self.screen(f"── ndf-relay: 区間 {self.section + 1} ──") + try: + self.start_section([command], cwd, command, m.get("session_id") or "", fb) + except StartFailed as e: + return self.give_up("start-failed", f"claude を起動できない({os.strerror(e.err)})", + command, errno=e.err) + + def close(self) -> None: + self.release_count() + remove(self.path(PID_FILE)) + try: + fcntl.flock(self.lock_fd, fcntl.LOCK_UN) + os.close(self.lock_fd) + except OSError: + pass + + +def goal_pending(transcript_path: str, written: float) -> bool: + """会話の記録に `/goal` の目標があり、印より後の判定の記録がまだ無ければ真。 + + 目標の設定と判定は `type: attachment` の行の `attachment.type: goal_status` に書かれる + (Claude Code 2.1.280 で実測)。設定は `sentinel: true`、判定は `met` の真偽を持つ。 + `met: true` で目標は終わる。判定は command hook(`mark`)より後に書かれる。 + """ + if not transcript_path: + return False + has_goal = False + judged_at = 0.0 + try: + with open(transcript_path, errors="replace") as f: + for line in f: + if "goal_status" not in line: + continue + try: + row = json.loads(line) + except ValueError: + continue + a = row.get("attachment") if isinstance(row, dict) else None + if row.get("type") != "attachment" or not isinstance(a, dict) \ + or a.get("type") != "goal_status": + continue + if a.get("sentinel"): + has_goal, judged_at = True, 0.0 + continue + judged_at = parse_iso(row.get("timestamp")) or judged_at + if a.get("met") is True: + has_goal = False + except OSError: + return False + return has_goal and judged_at <= written + + +def cmd_run(args: list[str]) -> int: + if os.environ.get("NDF_RELAY") == "0": + claude = resolve_claude() + if claude is None: + say("本物の claude が見つからない") + return 127 + passthrough(claude, args) + claude = resolve_claude() + if depth() >= 2: + say(f"起動が入れ子になっている({claude or '本物の claude が見つからない'})") + return 127 + if claude is None: + say("本物の claude が見つからない") + return 127 + if os.environ.get("NDF_RELAY_DIR") or needs_no_relay(args): + passthrough(claude, args) + if not (os.isatty(0) and os.isatty(1)): + passthrough(claude, args) + try: + import pty # noqa: F401 + import termios + import tty + except ImportError: + say("中継を始めない(擬似端末を作れない)。切れ目では示されたコマンドを手で入力する") + passthrough(claude, args) + got = read_plugin(claude) + if got is None: + say("中継を始めない(ndf のプラグインの名前と版を読めない)。切れ目では示されたコマンドを手で入力する") + passthrough(claude, args) + try: + relay_dir = make_relay_dir() + except OSError: + say("中継を始めない(作業ディレクトリを作れない)。切れ目では示されたコマンドを手で入力する") + passthrough(claude, args) + relay = Relay(claude, relay_dir, got[0], got[1]) + saved = termios.tcgetattr(0) + + def restore() -> None: + try: + termios.tcsetattr(0, termios.TCSAFLUSH, saved) + except (OSError, termios.error): + pass + + def on_signal(signum, _frame): + restore() + raise SystemExit(128 + signum) + + signal.signal(signal.SIGTERM, on_signal) + signal.signal(signal.SIGHUP, on_signal) + signal.signal(signal.SIGWINCH, lambda *_: relay.copy_winsize()) + try: + tty.setraw(0) + return relay.loop(args) + finally: + restore() + relay.close() + + +# ---------------------------------------------------------------- stop + + +def cmd_stop() -> int: + root = state_root() + found = 0 + try: + names = sorted(os.listdir(root)) + except OSError: + names = [] + for name in names: + d = os.path.join(root, name) + if not os.path.isdir(d) or not relay_running(d): + continue + with open(os.path.join(d, STOP_FILE), "w"): + pass + try: + with open(os.path.join(d, PID_FILE)) as f: + print(f.read().strip() or name) + except OSError: + print(name) + found += 1 + return 0 if found else 1 + + +# ---------------------------------------------------------------- install + + +DEF_RE = re.compile(r"^\s*(alias\s+claude=|function\s+claude\b|claude\s*\(\s*\))", re.M) + + +def _flock_wait(fd: int, seconds: float) -> bool: + end = time.time() + seconds + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except OSError: + if time.time() >= end: + return False + time.sleep(0.05) + + +def _read(path: str) -> str | None: + try: + with open(path, encoding="utf-8", errors="replace") as f: + return f.read() + except OSError: + return None + + +def _records(path: str) -> list[str]: + return (_read(path) or "").splitlines() + + +def _append_record(path: str, line: str) -> None: + with open(path, "a") as f: + f.write(line + "\n") + + +def place_copy() -> None: + dst = os.path.join(data_dir(), "relay.py") + src = os.path.realpath(__file__) + with open(src, "rb") as f: + body = f.read() + try: + with open(dst, "rb") as f: + if f.read() == body: + return + except OSError: + pass + tmp = f"{dst}.{os.getpid()}.tmp" + with open(tmp, "wb") as f: + f.write(body) + os.chmod(tmp, 0o755) + os.replace(tmp, dst) + + +def rc_target() -> tuple[str, list[str]] | None: + shell = os.path.basename(os.environ.get("SHELL", "")) + home = os.path.expanduser("~") + if shell == "bash": + return os.path.join(home, ".bashrc"), [os.path.join(home, ".bash_aliases")] + if shell == "zsh": + return os.path.join(os.environ.get("ZDOTDIR") or home, ".zshrc"), [] + return None + + +def install_once() -> str | None: + """I2〜I7。利用者へ知らせる 1 行を返す(知らせることが無ければ None)。""" + place_copy() + target = rc_target() + if target is None: + return None + rc, extra = target + body = _read(rc) + if body is not None and BLOCK_OPEN in body.splitlines(): + return None + root = state_root() + added, skipped = os.path.join(root, "rc-added"), os.path.join(root, "rc-skipped") + if rc in _records(added): + return None + if any(DEF_RE.search(_read(p) or "") for p in [rc] + extra): + if rc in _records(skipped): + return None + _append_record(skipped, rc) + return (f"ndf-relay: {rc} に claude の定義があるため alias を足さない。" + f"中継を使うなら {ALIAS_LINE} を自分で置く") + if body is not None: + stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + with open(f"{rc}.ndf-bak-{stamp}", "w", encoding="utf-8") as f: + f.write(body) + with open(rc, "a", encoding="utf-8") as f: + if body and not body.endswith("\n"): + f.write("\n") + f.write(("\n" if body else "") + RC_BLOCK) + _append_record(added, rc) + return (f"ndf-relay: {rc} へ alias claude を足した。次に開くシェルから効く" + f"(今のシェルでは source {rc})。戻すには囲みを消す") + + +def cmd_install() -> int: + if os.environ.get("NDF_RELAY_AUTO") == "0": + return 0 + try: + root = state_root() + os.makedirs(root, mode=0o700, exist_ok=True) + os.makedirs(data_dir(), mode=0o700, exist_ok=True) + fd = os.open(os.path.join(root, INSTALL_LOCK), os.O_RDWR | os.O_CREAT, 0o600) + try: + if not _flock_wait(fd, 2): + return 0 + msg = install_once() + finally: + os.close(fd) + except Exception: # SessionStart を止めない + return 0 + if msg: + print(json.dumps({"systemMessage": msg}, ensure_ascii=False)) + return 0 + + +# ---------------------------------------------------------------- main + + +def main(argv: list[str]) -> int: + if not argv: + print("usage: relay.py run|stop|mark|install", file=sys.stderr) + return 2 + sub, rest = argv[0], argv[1:] + if sub == "mark": + try: + return cmd_mark() + except Exception: # hook は Stop を止めない + return 0 + if sub == "run": + return cmd_run(rest) + if sub == "stop": + return cmd_stop() + if sub == "install": + return cmd_install() + if sub == "is-child": + # 文脈量の hook(token-guard.sh)が使う。中継が動いていて、hook を呼んだ claude が + # 中継の直接の子なら 0(親のたどりは mark と同じ。間の bash / sh は claude でないので飛ぶ) + d = os.environ.get("NDF_RELAY_DIR") + return 0 if d and relay_running(d) and is_direct_child(d) else 1 + print(f"relay.py: 未知の副命令 {sub}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/plugins/ndf/scripts/tests/fixtures/relay_fake_claude.py b/plugins/ndf/scripts/tests/fixtures/relay_fake_claude.py new file mode 100755 index 000000000..780076aac --- /dev/null +++ b/plugins/ndf/scripts/tests/fixtures/relay_fake_claude.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""relay.py の試験で本物の claude の代わりに起動する偽の claude(#895)。 + +- `plugin ...` の副命令: 呼び出しを `FAKE_DIR/calls.jsonl` へ記録する。`list --json` は + `ndf@mk` の要素を返す。版は `plugin update` が呼ばれた後なら `FAKE_VERSION_AFTER`、 + 前なら `FAKE_VERSION`(既定 1.0.0)。`FAKE_FAIL` に `list` / `update` / `marketplace` を + 含めると、その副命令が終了コード 1 で終わる。`FAKE_HANG` に含めると終わらない。 + `FAKE_BREAK_ON_LIST=N` なら N 回目の `list` の後に自分の実行権限を外す(次の exec が失敗する) +- それ以外: 対話の区間として動く。起動の記録(argv・cwd・pid・環境の一部)を + `FAKE_DIR/starts.jsonl` へ書き、端末を raw にして受けたバイトを `FAKE_DIR/input-` へ + 書く。行(`\\r` で終わる)ごとに次の命令を解く + +| 行 | すること | +| --- | --- | +| `/exit` | 終了コード 0 で終わる(`FAKE_IGNORE_EXIT=1` なら無視する) | +| `mark <中身>` | `relay.py mark` を Stop hook と同じ形で呼び、`<中身>` の `ndf-next` のブロックを出す | +| `quit ` | 終了コード n で終わる | +| `size` | 端末の大きさを `FAKE_DIR/size-` へ書く | +| `tr ` | 会話の記録(`FAKE_DIR/transcript-.jsonl`)へ 1 行足す | + +最初の位置引数が `mark ` で始まれば、起動の直後にその行を 1 度実行する。 +""" +import fcntl +import json +import os +import signal +import struct +import subprocess +import sys +import termios +import time +import tty + +D = os.environ["FAKE_DIR"] +RELAY = os.environ["FAKE_RELAY"] + + +def log(name, row): + with open(os.path.join(D, name), "a") as f: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def plugin(args): + log("calls.jsonl", {"args": args, "env_relay_dir": os.environ.get("NDF_RELAY_DIR")}) + kind = "list" if args[:1] == ["list"] else "marketplace" if args[:1] == ["marketplace"] else "update" + if kind in os.environ.get("FAKE_HANG", "").split(","): + time.sleep(3600) + if kind in os.environ.get("FAKE_FAIL", "").split(","): + sys.exit(1) + if kind == "list": + calls = [json.loads(x) for x in open(os.path.join(D, "calls.jsonl"))] + updated = any(c["args"][:1] == ["update"] for c in calls) + ver = os.environ.get("FAKE_VERSION_AFTER" if updated else "FAKE_VERSION") \ + or os.environ.get("FAKE_VERSION") or "1.0.0" + print(json.dumps([{"id": "other@x", "version": "9"}, {"id": "ndf@mk", "version": ver}])) + lists = sum(c["args"][:1] == ["list"] for c in calls) + if str(lists) == os.environ.get("FAKE_BREAK_ON_LIST"): + os.chmod(sys.argv[0], 0o644) # 次の exec を失敗させる + sys.exit(0) + + +def transcript(): + return os.path.join(D, f"transcript-{os.getpid()}.jsonl") + + +def do_mark(body): + msg = f"次の区間:\n\n```ndf-next\n{body}\n```" + data = {"session_id": f"s{os.getpid()}", "transcript_path": transcript(), "cwd": os.getcwd(), + "stop_hook_active": False, "last_assistant_message": msg, "background_tasks": []} + subprocess.run([sys.executable, RELAY, "mark"], input=json.dumps(data), text=True) + + +def handle(line): + if line == "/exit": + if os.environ.get("FAKE_IGNORE_EXIT") != "1": + sys.exit(0) + elif line.startswith("mark "): + do_mark(line[5:]) + elif line.startswith("quit "): + sys.exit(int(line[5:])) + elif line == "size": + ws = fcntl.ioctl(0, termios.TIOCGWINSZ, b"\0" * 8) + rows, cols = struct.unpack("HHHH", ws)[:2] + with open(os.path.join(D, f"size-{os.getpid()}"), "w") as f: + f.write(f"{rows} {cols}") + elif line.startswith("tr "): + with open(transcript(), "a") as f: + f.write(line[3:] + "\n") + + +def main(): + args = sys.argv[1:] + if args[:1] == ["plugin"]: + plugin(args[1:]) + if os.environ.get("FAKE_IGNORE_EXIT") == "1": + signal.signal(signal.SIGTERM, signal.SIG_IGN) + log("starts.jsonl", {"argv": args, "cwd": os.getcwd(), "pid": os.getpid(), + "relay_dir": os.environ.get("NDF_RELAY_DIR"), + "claudecode": os.environ.get("CLAUDECODE"), + "depth": os.environ.get("NDF_RELAY_DEPTH")}) + open(transcript(), "a").close() + if args and args[0].startswith("mark "): + do_mark(args[0][5:]) + tty.setraw(0) + buf = b"" + raw = open(os.path.join(D, f"input-{os.getpid()}"), "ab", buffering=0) + while True: + data = os.read(0, 1024) + if not data: + return + raw.write(data) + buf += data + while b"\r" in buf: + line, buf = buf.split(b"\r", 1) + handle(line.decode(errors="replace")) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/scripts/tests/test_relay.py b/plugins/ndf/scripts/tests/test_relay.py new file mode 100644 index 000000000..2c5f41602 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_relay.py @@ -0,0 +1,1018 @@ +"""区間の切れ目で claude を起動し直す中継(#895)。 + +`relay.py` は 4 つの副命令を持つ。 + +- `mark`: Stop hook の本体。最後の応答の `ndf-next` のブロックを印 `next.json` へ写す +- `run`: 端末の前景に常駐し、claude を擬似端末の子として起動する。要らなければ素通しする +- `stop`: 動いている中継に停止の印を置く +- `install`: 中継を安定した場所へ置き直し、シェルの設定へ alias を 1 度だけ足す + +**利用者の手元の設定は書き換えない。** `install` と `run` は、テストごとの一時の HOME と +`XDG_*` の下でだけ動かす(`isolated_env`)。 +""" +from __future__ import annotations + +import fcntl +import json +import os +import pathlib +import subprocess +import sys + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RELAY = ROOT / "scripts" / "relay.py" + + +def isolated_env(tmp_path, **extra): + """一時の HOME と XDG_* だけを持つ環境。本物の HOME を指さないことを確かめてから返す。""" + home = tmp_path / "home" + home.mkdir(exist_ok=True) + e = {k: v for k, v in os.environ.items() + if not k.startswith(("NDF_", "XDG_", "CLAUDE")) and k not in ("ZDOTDIR",)} + e["HOME"] = str(home) + e.update({k: str(v) for k, v in extra.items()}) + real_home = os.path.expanduser("~") + assert e["HOME"] != real_home + return e + + +def fence(body, info="ndf-next", ticks=3): + return f"{'`' * ticks}{info}\n{body}\n{'`' * ticks}" + + +# ---------------------------------------------------------------- mark(AC3 / AC4 / AC4b / AC24) + + +class Relay: + """動いている中継に見立てた作業ディレクトリ。`relay.lock` を持ち、`child.pid` を書く。""" + + def __init__(self, tmp_path, child_pid=None): + self.dir = tmp_path / "relay-dir" + self.dir.mkdir(mode=0o700) + self.lock = open(self.dir / "relay.lock", "a") + fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + # mark の親はこのテストのプロセスなので、既定では直接の子の claude に当たる + (self.dir / "child.pid").write_text(str(child_pid or os.getpid())) + + def release(self): + fcntl.flock(self.lock, fcntl.LOCK_UN) + self.lock.close() + + @property + def next(self): + return self.dir / "next.json" + + +@pytest.fixture() +def relay(tmp_path): + r = Relay(tmp_path) + yield r + if not r.lock.closed: + r.release() + + +def stop_input(message, **extra): + d = {"session_id": "sess-1", "transcript_path": "/tmp/t.jsonl", "cwd": "/work/x", + "stop_hook_active": False, "last_assistant_message": message, + "background_tasks": []} + d.update(extra) + return json.dumps(d) + + +def mark(env_dir, data, env=None, argv0=None): + e = {k: v for k, v in os.environ.items() if not k.startswith("NDF_")} + if env_dir is not None: + e["NDF_RELAY_DIR"] = str(env_dir) + if env: + e.update(env) + cmd = [sys.executable, str(RELAY), "mark"] + return subprocess.run(cmd, input=data, capture_output=True, text=True, env=e, timeout=20) + + +def quiet_ok(proc): + assert proc.returncode == 0, proc.stderr + assert proc.stdout == "" + assert proc.stderr == "" + + +def test_mark_writes_next(relay): + msg = "承認を受けて設計をマージした。\n\n" + fence("/goal /ndf:development-workflow #895") + quiet_ok(mark(relay.dir, stop_input(msg))) + data = json.loads(relay.next.read_text()) + assert set(data) == {"command", "cwd", "session_id", "transcript_path", "written_at"} + assert data["command"] == "/goal /ndf:development-workflow #895" + assert data["cwd"] == "/work/x" + assert data["session_id"] == "sess-1" + assert data["transcript_path"] == "/tmp/t.jsonl" + assert data["written_at"].endswith("Z") + + +def test_mark_multiline_command(relay): + quiet_ok(mark(relay.dir, stop_input(fence("/goal a\n\n二行目")))) + assert json.loads(relay.next.read_text())["command"] == "/goal a\n\n二行目" + + +def test_mark_ignores_quoted_block(relay): + msg = "例:\n\n" + fence(fence("/goal x"), info="markdown", ticks=4) + quiet_ok(mark(relay.dir, stop_input(msg))) + assert not relay.next.exists() + + +def test_mark_writes_even_when_stop_hook_active(relay): + quiet_ok(mark(relay.dir, stop_input(fence("/goal x"), stop_hook_active=True))) + assert relay.next.exists() + + +@pytest.mark.parametrize("case", ["no-dir", "not-running", "zero", "two", "quoted-only", + "broken-json", "not-direct-child", "text-info"]) +def test_mark_does_nothing(tmp_path, relay, case): + msg = fence("/goal x") + env_dir = relay.dir + data = stop_input(msg) + if case == "no-dir": + env_dir = None + elif case == "not-running": + relay.release() + elif case == "zero": + data = stop_input("ブロックは無い") + elif case == "two": + data = stop_input(msg + "\n\n" + fence("/goal y")) + elif case == "quoted-only": + data = stop_input(fence(msg, info="", ticks=4)) + elif case == "broken-json": + data = "{not json" + elif case == "text-info": + data = stop_input(fence("/goal x", info="text")) + elif case == "not-direct-child": + (relay.dir / "child.pid").write_text("1") + quiet_ok(mark(env_dir, data)) + assert not relay.next.exists() + + +def test_mark_not_direct_child_claude_keeps_mark(tmp_path, relay): + """conductor が Bash から起こした `claude -p` の Stop は、前の印を消さない(AC4b)。""" + relay.next.write_text('{"command": "keep"}') + # 名前が claude のラッパーを間に挟む。mark から見て最初の claude はこのラッパーになる + wrapper = tmp_path / "bin" / "claude" + wrapper.parent.mkdir() + wrapper.write_text(f"#!{sys.executable}\nimport subprocess, sys\n" + f"sys.exit(subprocess.run([{sys.executable!r}, {str(RELAY)!r}, 'mark']).returncode)\n") + wrapper.chmod(0o755) + e = {k: v for k, v in os.environ.items() if not k.startswith("NDF_")} + e["NDF_RELAY_DIR"] = str(relay.dir) + for msg in ("ブロックは無い", fence("/goal other")): + p = subprocess.run([str(wrapper)], input=stop_input(msg), capture_output=True, + text=True, env=e, timeout=20) + quiet_ok(p) + assert relay.next.read_text() == '{"command": "keep"}' + + +def test_mark_clears_previous_when_no_block(relay): + quiet_ok(mark(relay.dir, stop_input(fence("/goal x")))) + assert relay.next.exists() + quiet_ok(mark(relay.dir, stop_input("続きの応答"))) + assert not relay.next.exists() + + +def test_mark_background_running_clears(relay): + """背景の処理が動いているあいだは印を書かず、前の印を消す(AC24)。""" + quiet_ok(mark(relay.dir, stop_input(fence("/goal x")))) + running = [{"id": "b1", "type": "shell", "status": "running"}] + quiet_ok(mark(relay.dir, stop_input(fence("/goal x"), background_tasks=running))) + assert not relay.next.exists() + done = [{"id": "b1", "type": "shell", "status": "completed"}] + quiet_ok(mark(relay.dir, stop_input(fence("/goal x"), background_tasks=done))) + assert relay.next.exists() + + +def test_mark_file_is_private(relay): + quiet_ok(mark(relay.dir, stop_input(fence("/goal x")))) + assert relay.next.stat().st_mode & 0o077 == 0 + + +# ---------------------------------------------------------------- run(擬似端末の上で動かす) + +import pty +import struct +import signal +import termios +import threading +import time + +FAKE = ROOT / "scripts" / "tests" / "fixtures" / "relay_fake_claude.py" + +FAST = {"NDF_RELAY_QUIET": "0.3", "NDF_RELAY_POLL": "0.1", "NDF_RELAY_EXIT_GAP": "0.1", + "NDF_RELAY_EXIT_WAIT": "5", "NDF_RELAY_TERM_WAIT": "2"} + + +class Term: + """擬似端末の上で `relay.py run` を動かす。画面の出力は裏のスレッドで読み続ける。""" + + def __init__(self, tmp_path, args=(), env=None, cwd=None, rows=24, cols=80, cmd=None): + self.fake_dir = tmp_path / "fake" + self.fake_dir.mkdir(exist_ok=True) + e = isolated_env(tmp_path, NDF_RELAY_CLAUDE=FAKE, FAKE_DIR=self.fake_dir, + FAKE_RELAY=RELAY, **FAST) + e.update({k: str(v) for k, v in (env or {}).items()}) + self.env = e + self.master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + self.before = termios.tcgetattr(slave) + self.slave = slave + cmd = cmd or [sys.executable, str(RELAY), "run"] + self.proc = subprocess.Popen([*cmd, *args], stdin=slave, + stdout=slave, stderr=slave, env=e, cwd=cwd or tmp_path, + start_new_session=True) + self.out = b"" + self.lock = threading.Lock() + self.reader = threading.Thread(target=self._read, daemon=True) + self.reader.start() + + def _read(self): + while True: + try: + data = os.read(self.master, 65536) + except OSError: + return + if not data: + return + with self.lock: + self.out += data + + @property + def text(self): + with self.lock: + return self.out.decode(errors="replace") + + def type(self, s): + os.write(self.master, s.encode() if isinstance(s, str) else s) + + def wait(self, pred, timeout=15, what=""): + end = time.time() + timeout + while time.time() < end: + if pred(): + return + time.sleep(0.05) + raise AssertionError(f"待ちが切れた: {what}\n画面: {self.text[-2000:]}\n" + f"記録: {self.rows()}") + + def starts(self): + p = self.fake_dir / "starts.jsonl" + return [json.loads(x) for x in p.read_text().splitlines()] if p.exists() else [] + + def calls(self): + p = self.fake_dir / "calls.jsonl" + return [json.loads(x)["args"] for x in p.read_text().splitlines()] if p.exists() else [] + + def relay_dirs(self): + root = pathlib.Path(self.env["HOME"]) / ".local" / "state" / "ndf" / "relay" + return sorted(p for p in root.iterdir() if p.is_dir()) if root.exists() else [] + + def rows(self): + out = [] + for d in self.relay_dirs(): + p = d / "log.jsonl" + if p.exists(): + out += [json.loads(x) for x in p.read_text().splitlines()] + return out + + def child_input(self, n): + pid = self.starts()[n]["pid"] + p = self.fake_dir / f"input-{pid}" + return p.read_bytes() if p.exists() else b"" + + def wait_start(self, n, timeout=15): + self.wait(lambda: len(self.starts()) >= n, timeout, f"{n} 回目の起動") + # 子が端末を raw にするまで少し待つ(raw の前の入力は行の編集に掛かる) + time.sleep(0.3) + + def finish(self, timeout=20): + try: + return self.proc.wait(timeout) + except subprocess.TimeoutExpired: + raise AssertionError(f"中継が終わらない\n画面: {self.text[-2000:]}\n記録: {self.rows()}") + + def close(self): + if self.proc.poll() is None: + self.proc.kill() + self.proc.wait(5) + for fd in (self.master, self.slave): + try: + os.close(fd) + except OSError: + pass + + + +@pytest.fixture() +def term(tmp_path): + made = [] + + def make(*args, **kw): + t = Term(tmp_path, args, **kw) + made.append(t) + return t + yield make + for t in made: + t.close() + + +def events(rows, kind): + return [r for r in rows if r["event"] == kind] + + +def test_run_no_mark_exits_with_child_code(term): + """印が無いまま claude が終わると、中継も何も出さずに同じ終了コードで終わる(AC12・AC19)。""" + t = term() + t.wait_start(1) + t.type("quit 3\r") + assert t.finish() == 3 + assert "ndf-relay" not in t.text + assert t.starts()[0]["argv"] == [] + rows = t.rows() + assert [r["event"] for r in rows] == ["start", "end"] + assert rows[1]["ended_by"] == "no-mark" + assert set(rows[1]) == {"event", "at", "section", "pid", "seconds", "ended_by"} + + +def test_run_first_section_gets_args_and_clean_env(term): + t = term("--model", "haiku", "-c", env={"CLAUDECODE": "1"}) + t.wait_start(1) + s = t.starts()[0] + assert s["argv"] == ["--model", "haiku", "-c"] + assert s["claudecode"] is None + assert s["depth"] == "1" + assert s["relay_dir"] and pathlib.Path(s["relay_dir"]).stat().st_mode & 0o777 == 0o700 + t.type("/exit\r") + assert t.finish() == 0 + + +def test_run_signal_exit_code(term): + t = term() + t.wait_start(1) + os.kill(t.starts()[0]["pid"], signal.SIGTERM) + assert t.finish() == 143 + + +def test_run_switches_to_next_section(term, tmp_path): + """印を受けると /exit と \\r を入力し、更新して次の区間を起動する(AC6・AC8・AC19)。""" + t = term("--model", "haiku", env={"FAKE_VERSION_AFTER": "2.0.0"}) + t.wait_start(1) + t.type("mark /goal 次の段\r") + t.wait_start(2) + first, second = t.starts()[:2] + assert second["argv"] == ["/goal 次の段"] + assert t.child_input(0).endswith(b"/exit\r") + assert t.calls() == [["list", "--json"], ["marketplace", "update", "mk"], + ["update", "ndf@mk", "-y"], ["list", "--json"]] + assert "── ndf-relay: 区間 2 ──" in t.text + rows = t.rows() + assert [r["event"] for r in rows] == ["start", "end", "start"] + s1, e1, s2 = rows + assert set(s1) == {"event", "at", "section", "pid", "command", "from_session", + "plugin_version", "cwd"} + assert s1["command"] == "--model haiku" + assert s1["plugin_version"] == "1.0.0" + assert s2["plugin_version"] == "2.0.0" + assert s2["command"] == "/goal 次の段" + assert s2["from_session"] == f"s{first['pid']}" + assert s2["section"] == 2 and s2["pid"] == second["pid"] + assert e1["ended_by"] == "mark" and e1["section"] == 1 + t.type("quit 0\r") + assert t.finish() == 0 + + +def test_run_chain_three_sections(term): + """人が何も入力せずに 3 つ目の区間まで起動する(AC17 の試験用の形)。""" + t = term() + t.wait_start(1) + t.type("mark mark 三つ目\r") + t.wait_start(3, timeout=25) + assert [s["argv"] for s in t.starts()] == [[], ["mark 三つ目"], ["三つ目"]] + rows = t.rows() + assert len(events(rows, "start")) == 3 + assert [r["ended_by"] for r in events(rows, "end")] == ["mark", "mark"] + t.type("/exit\r") + assert t.finish() == 0 + + +def test_run_waits_for_user_input_quiet(term): + """利用者の入力から静まりの秒数がたつまで /exit を送らない(AC6)。""" + t = term(env={"NDF_RELAY_QUIET": "1.5"}) + t.wait_start(1) + t.type("mark x\r") + for _ in range(6): + time.sleep(0.4) + t.type("a") # 打っている途中 + assert len(t.starts()) == 1 + assert b"/exit" not in t.child_input(0) + t.wait_start(2) + + +def test_run_waits_for_transcript_quiet(term): + t = term(env={"NDF_RELAY_QUIET": "1.5"}) + t.wait_start(1) + t.type("mark x\r") + tp = t.fake_dir / f"transcript-{t.starts()[0]['pid']}.jsonl" + for _ in range(6): + time.sleep(0.4) + with open(tp, "a") as f: + f.write('{"type": "assistant"}\n') + assert len(t.starts()) == 1 + t.wait_start(2) + + +def test_run_mark_removed_cancels(term): + """印が消えたら(次の Stop にブロックが無い)切り替えない(AC4b・AC6)。""" + t = term(env={"NDF_RELAY_QUIET": "1"}) + t.wait_start(1) + t.type("mark x\r") + d = pathlib.Path(t.starts()[0]["relay_dir"]) + t.wait(lambda: (d / "next.json").exists(), what="印") + (d / "next.json").unlink() + time.sleep(2) + assert len(t.starts()) == 1 + t.type("quit 0\r") + assert t.finish() == 0 + + +def test_run_forwards_keys_and_winsize(term): + """キー入力(Ctrl-C を含む)はそのまま子へ届き、大きさの変化も伝わる(AC7)。""" + t = term(rows=30, cols=100) + t.wait_start(1) + t.type(b"ab\x03cd") + t.wait(lambda: t.child_input(0) == b"ab\x03cd", what="子への入力") + t.type("\rsize\r") + pid = t.starts()[0]["pid"] + size = t.fake_dir / f"size-{pid}" + t.wait(size.exists, what="大きさ") + assert size.read_text() == "30 100" + size.unlink() + fcntl.ioctl(t.master, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0)) + os.kill(t.proc.pid, signal.SIGWINCH) + time.sleep(0.3) + t.type("size\r") + t.wait(size.exists, what="大きさ") + assert size.read_text() == "40 120" + t.type("/exit\r") + t.finish() + + +@pytest.mark.parametrize("how", ["exit", "sigterm", "sighup"]) +def test_run_restores_terminal(term, how): + t = term() + t.wait_start(1) + assert termios.tcgetattr(t.slave) != t.before # raw になっている + if how == "exit": + t.type("/exit\r") + else: + os.kill(t.proc.pid, signal.SIGTERM if how == "sigterm" else signal.SIGHUP) + t.finish() + assert termios.tcgetattr(t.slave) == t.before + + +def test_run_exception_keeps_child_and_restores(term, tmp_path): + """本体の例外は捕まえて stop の行と 1 行に変え、子を巻き込まない(非機能・AC7)。""" + wrapper = tmp_path / "wrap.py" + wrapper.write_text( + "import importlib.util, sys\n" + f"spec = importlib.util.spec_from_file_location('relay', {str(RELAY)!r})\n" + "m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)\n" + "def boom(*a, **k): raise RuntimeError('boom')\n" + "m.Relay._tick = boom\n" + "sys.exit(m.main(sys.argv[1:]))\n") + t = term(cmd=[sys.executable, str(wrapper), "run"]) + t.wait_start(1) + t.wait(lambda: "ndf-relay: 次の区間を起動しない" in t.text, what="例外の 1 行") + assert events(t.rows(), "stop")[0]["reason"] == "error" + t.type(b"ok") + t.wait(lambda: t.child_input(0) == b"ok", what="例外の後も入力が届く") + t.type("\rquit 4\r") + assert t.finish() == 4 + assert len(events(t.rows(), "stop")) == 1 + assert termios.tcgetattr(t.slave) == t.before + + +# ---------------------------------------------------------------- 素通しと本物の claude(AC15 / AC20) + +import importlib.util # noqa: E402 + + +class Execd(Exception): + pass + + +@pytest.fixture() +def mod(tmp_path, monkeypatch): + """relay.py を読み込み、os.execve を差し替える。環境は一時の HOME にそろえる。""" + spec = importlib.util.spec_from_file_location("relay_under_test", RELAY) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + for k in list(os.environ): + if k.startswith(("NDF_", "XDG_", "CLAUDE")): + monkeypatch.delenv(k, raising=False) + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + calls = [] + + def fake_execve(path, argv, env): + calls.append((path, argv, dict(env))) + raise Execd() + monkeypatch.setattr(m.os, "execve", fake_execve) + m.calls = calls + return m + + +def real_claude(tmp_path, name="real"): + p = tmp_path / name / "claude" + p.parent.mkdir(parents=True) + p.write_text("#!/bin/sh\nexit 0\n") + p.chmod(0o755) + return p + + +@pytest.mark.parametrize("case", ["relay-off", "under-relay", "print", "help", "subcommand", + "stdin-pipe"]) +def test_run_passthrough(mod, tmp_path, monkeypatch, capsys, case): + claude = real_claude(tmp_path) + monkeypatch.setenv("NDF_RELAY_CLAUDE", str(claude)) + monkeypatch.setattr(mod.os, "isatty", lambda fd: case != "stdin-pipe") + args = {"print": ["-p", "hi"], "help": ["--help"], "subcommand": ["mcp", "list"]}.get( + case, ["--model", "haiku"]) + if case == "relay-off": + monkeypatch.setenv("NDF_RELAY", "0") + if case == "under-relay": + monkeypatch.setenv("NDF_RELAY_DIR", str(tmp_path)) + before = dict(os.environ) + with pytest.raises(Execd): + mod.cmd_run(args) + path, argv, env = mod.calls[0] + assert path == str(claude) + assert argv == [str(claude)] + args + diff = {k for k in set(env) | set(before) if env.get(k) != before.get(k)} + assert diff == {"NDF_RELAY_DEPTH"} + assert env["NDF_RELAY_DEPTH"] == "1" + out = capsys.readouterr() + assert out.out == "" and out.err == "" + + +@pytest.mark.parametrize("case", ["no-pty", "list-fails", "no-ndf", "list-hangs"]) +def test_run_cannot_start_says_then_passthrough(mod, tmp_path, monkeypatch, capsys, case): + claude = tmp_path / "bin" / "claude" + claude.parent.mkdir() + body = {"list-fails": "exit 1", "no-ndf": "echo '[{\"id\": \"x@y\", \"version\": \"1\"}]'", + "list-hangs": "sleep 30"}.get(case, "echo '[{\"id\": \"ndf@m\", \"version\": \"1\"}]'") + claude.write_text(f"#!/bin/sh\n{body}\n") + claude.chmod(0o755) + monkeypatch.setenv("NDF_RELAY_CLAUDE", str(claude)) + monkeypatch.setenv("NDF_RELAY_LIST_TIMEOUT", "0.5") + monkeypatch.setattr(mod.os, "isatty", lambda fd: True) + if case == "no-pty": + monkeypatch.setitem(sys.modules, "pty", None) + with pytest.raises(Execd): + mod.cmd_run([]) + err = capsys.readouterr().err + assert err.startswith("ndf-relay: 中継を始めない(") and err.count("\n") == 1 + assert mod.calls[0][1] == [str(claude)] + + +def test_resolve_skips_wrappers(mod, tmp_path, monkeypatch): + """`claude` という名前で中継を呼ぶラッパーを飛ばし、本物を選ぶ(AC20)。""" + wrapper = tmp_path / "wrap" / "claude" + wrapper.parent.mkdir() + wrapper.write_text(f'#!/bin/sh\nexec python3 "{RELAY}" run "$@"\n') + wrapper.chmod(0o755) + link = tmp_path / "link" / "claude" + link.parent.mkdir() + link.symlink_to(RELAY) + real = real_claude(tmp_path) + monkeypatch.setenv("PATH", os.pathsep.join([str(wrapper.parent), str(link.parent), + str(real.parent), "/usr/bin"])) + assert mod.resolve_claude() == str(real) + forced = real_claude(tmp_path, "forced") + monkeypatch.setenv("NDF_RELAY_CLAUDE", str(forced)) + assert mod.resolve_claude() == str(forced) + + +def test_run_nested_stops_127(tmp_path): + e = isolated_env(tmp_path, NDF_RELAY_DEPTH=2, NDF_RELAY_CLAUDE=real_claude(tmp_path)) + p = subprocess.run([sys.executable, str(RELAY), "run"], capture_output=True, text=True, + env=e, timeout=20) + assert p.returncode == 127 + assert p.stderr.startswith("ndf-relay: 起動が入れ子になっている(") and p.stderr.count("\n") == 1 + + +def test_run_no_real_claude_127(tmp_path): + e = isolated_env(tmp_path, PATH=str(tmp_path / "empty")) + p = subprocess.run([sys.executable, str(RELAY), "run", "-p", "x"], capture_output=True, + text=True, env=e, timeout=20) + assert p.returncode == 127 + assert "本物の claude が見つからない" in p.stderr + + +def test_run_passthrough_real_exec(tmp_path): + """素通しは exec で置き換わり、終了コードと出力がそのまま返る。""" + claude = tmp_path / "bin" / "claude" + claude.parent.mkdir() + claude.write_text('#!/bin/sh\necho "args:$*"\nexit 7\n') + claude.chmod(0o755) + e = isolated_env(tmp_path, NDF_RELAY_CLAUDE=claude) + p = subprocess.run([sys.executable, str(RELAY), "run", "-p", "a b"], capture_output=True, + text=True, env=e, timeout=20) + assert (p.returncode, p.stdout, p.stderr) == (7, "args:-p a b\n", "") + + +# ---------------------------------------------------------------- 切り替えの細部・上限・止め方(AC9〜AC14 / AC26) + + +def test_run_cwd_fallback_to_main(term, tmp_path): + main = tmp_path / "main" + wt = main / ".worktrees" / "design" / "x" + wt.mkdir(parents=True) + t = term(env={"NDF_RELAY_QUIET": "1.5"}, cwd=wt) + t.wait_start(1) + t.type("mark next\r") + d = pathlib.Path(t.starts()[0]["relay_dir"]) + t.wait(lambda: (d / "next.json").exists(), what="印") + wt.rmdir() + t.wait_start(2) + assert t.starts()[1]["cwd"] == str(main) + s2 = events(t.rows(), "start")[1] + assert s2["cwd"] == str(main) and s2["cwd_fallback"] == str(wt) + t.type("/exit\r") + t.finish() + + +def seed_starts(home, n, name="old"): + d = pathlib.Path(home) / ".local" / "state" / "ndf" / "relay" / name + d.mkdir(parents=True, exist_ok=True) + at = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()) + with open(d / "log.jsonl", "a") as f: + for i in range(n): + f.write(json.dumps({"event": "start", "at": at, "section": i + 1}) + "\n") + + +def test_run_max_starts_keeps_section(term): + t = term() + seed_starts(t.env["HOME"], 20) + t.wait_start(1) + t.type("mark next\r") + t.wait(lambda: "ndf-relay: 次の区間を起動しない" in t.text, what="上限の 1 行") + time.sleep(0.5) + assert len(t.starts()) == 1 + assert b"/exit" not in t.child_input(0) + assert events(t.rows(), "stop")[0]["reason"] == "max-starts" + assert "/exit して示されたコマンドを手で入力する" in t.text + t.type("quit 5\r") + assert t.finish() == 5 + assert events(t.rows(), "end")[0]["ended_by"] == "no-mark" + + +def test_run_max_starts_race_one_wins(tmp_path): + """残り 1 枠を 2 つの中継が取り合っても、起動するのは 1 つだけ(AC10)。""" + home = tmp_path / "shared-home" + home.mkdir() + ts = [] + try: + for name in ("a", "b"): + (tmp_path / name).mkdir() + ts.append(Term(tmp_path / name, env={"HOME": home})) + seed_starts(home, 17) # 2 つの 1 つ目の区間で 19。残り 1 枠 + for t in ts: + t.wait_start(1) + for t in ts: + t.type("mark next\r") + for t in ts: + t.wait(lambda t=t: any(r["event"] in ("stop",) for r in t.rows()) + or len(t.starts()) >= 2, what="どちらかに決まる") + started = sorted(len(t.starts()) for t in ts) + assert started == [1, 2] + loser = next(t for t in ts if len(t.starts()) == 1) + assert [r["reason"] for r in events(loser.rows(), "stop")] == ["max-starts"] + finally: + for t in ts: + t.close() + + +def test_run_spin_stops_third(term): + """3 つ続けて短い区間なら 3 つ目の印で切り替えない。1 つ目の区間も数える(AC11)。""" + t = term(env={"NDF_RELAY_SPIN": "3"}) + t.wait_start(1) + t.type("mark mark mark 終点\r") + t.wait_start(3, timeout=25) + t.wait(lambda: events(t.rows(), "stop"), what="空回りの stop") + assert events(t.rows(), "stop")[0]["reason"] == "spin" + time.sleep(0.5) + assert len(t.starts()) == 3 + assert b"/exit" not in t.child_input(2) + t.type("/exit\r") + assert t.finish() == 0 + + +def test_run_spin_broken_by_long_section(term): + t = term(env={"NDF_RELAY_SPIN": "3"}) + t.wait_start(1) + t.type("mark B\r") + t.wait_start(2) + time.sleep(3.5) # 2 つ目の区間を長くする + t.type("mark mark C\r") + t.wait_start(4, timeout=25) + assert not events(t.rows(), "stop") + t.type("/exit\r") + t.finish() + + +def test_run_sigkill_when_child_ignores_exit(term): + t = term(env={"FAKE_IGNORE_EXIT": "1", "NDF_RELAY_EXIT_WAIT": "0.5", + "NDF_RELAY_TERM_WAIT": "0.5"}) + t.wait_start(1) + t.type("mark next\r") + t.wait_start(2) + e1 = events(t.rows(), "end")[0] + assert e1["ended_by"] == "sigkill" + assert e1["seconds"] < 1.5 # 印の written_at まで。/exit の後の待ちを含めない + os.kill(t.starts()[1]["pid"], signal.SIGKILL) + t.finish() + + +def test_run_update_failed_prints_next_command(term): + t = term(env={"FAKE_FAIL": "update"}) + t.wait_start(1) + t.type("mark /goal 続き\r") + assert t.finish() == 2 + assert "ndf-relay: 次の区間を起動できない(" in t.text + assert "次のコマンド:" in t.text and "/goal 続き" in t.text + rows = t.rows() + assert [r["event"] for r in rows] == ["start", "end", "stop"] + assert rows[-1]["reason"] == "update-failed" + + +def test_run_update_hang_times_out(term): + t = term(env={"FAKE_HANG": "update", "NDF_RELAY_UPDATE_TIMEOUT": "0.5"}) + t.wait_start(1) + t.type("mark x\r") + assert t.finish() == 2 + assert events(t.rows(), "stop")[0]["reason"] == "update-failed" + + +def test_run_next_start_failed(term): + t = term() + t.close() + copy = t.fake_dir / "claude-copy.py" + copy.write_bytes(FAKE.read_bytes()) + copy.chmod(0o755) + t2 = term(env={"FAKE_BREAK_ON_LIST": "2", "NDF_RELAY_CLAUDE": copy}) + t2.wait_start(1) + t2.type("mark /goal 続き\r") + assert t2.finish() == 2 + rows = t2.rows() + assert [r["event"] for r in rows] == ["start", "end", "stop"] + assert rows[-1]["reason"] == "start-failed" and rows[-1]["errno"] == 13 + assert "/goal 続き" in t2.text + + +def test_run_first_start_failed(term): + t = term() + t.close() + copy = t.fake_dir / "claude-copy.py" + copy.write_bytes(FAKE.read_bytes()) + copy.chmod(0o755) + t2 = term(env={"FAKE_BREAK_ON_LIST": "1", "NDF_RELAY_CLAUDE": copy}) + assert t2.finish() == 127 + assert "ndf-relay: claude を起動できない(" in t2.text + rows = t2.rows() + assert [(r["event"], r.get("reason")) for r in rows] == [("stop", "start-failed")] + + +def test_stop_marks_running_relays_only(term, tmp_path): + t = term() + t.wait_start(1) + root = pathlib.Path(t.env["HOME"]) / ".local" / "state" / "ndf" / "relay" + dead = root / "dead" + dead.mkdir() + (dead / "relay.lock").touch() + (dead / "relay.pid").write_text("999999") + alive_other = root / "reused" + alive_other.mkdir() + (alive_other / "relay.lock").touch() + (alive_other / "relay.pid").write_text(str(os.getpid())) + p = subprocess.run([sys.executable, str(RELAY), "stop"], capture_output=True, text=True, + env=t.env, timeout=20) + assert p.returncode == 0 + assert p.stdout.split() == [str(t.proc.pid)] + assert not (dead / "stop").exists() and not (alive_other / "stop").exists() + t.type("mark next\r") + t.wait(lambda: events(t.rows(), "stop"), what="停止の印") + assert events(t.rows(), "stop")[0]["reason"] == "stop-file" + assert len(t.starts()) == 1 + t.type("/exit\r") + t.finish() + p = subprocess.run([sys.executable, str(RELAY), "stop"], capture_output=True, text=True, + env=t.env, timeout=20) + assert p.returncode == 1 + + +def test_make_relay_dir_is_new_each_time(mod, tmp_path, monkeypatch): + """pid が同じでも作業ディレクトリは起動ごとに新しい。親が無くても作る(AC26)。""" + monkeypatch.setattr(mod.os, "getpid", lambda: 4242) + monkeypatch.setattr(mod.time, "gmtime", lambda *a: time.struct_time((2026, 9, 23, 0, 0, 0, 2, 266, 0))) + a = mod.make_relay_dir() + (pathlib.Path(a) / "stop").touch() + (pathlib.Path(a) / "next.json").write_text("{}") + b = mod.make_relay_dir() + assert a != b + assert os.listdir(b) == [] + assert pathlib.Path(b).stat().st_mode & 0o777 == 0o700 + + +def goal_row(sentinel=False, met=False, at=None): + a = {"type": "goal_status", "met": met, "condition": "c"} + if sentinel: + a["sentinel"] = True + ts = at or time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()) + return json.dumps({"type": "attachment", "timestamp": ts, "attachment": a}) + + +def test_run_waits_for_goal_judgement(term): + """目標のある区間では、印の後の目標の判定の記録がそろうまで /exit を送らない(AC6)。""" + t = term() + t.wait_start(1) + t.type(f"tr {goal_row(sentinel=True, at='2026-01-01T00:00:00.000Z')}\r") + t.type("mark next\r") + time.sleep(1.5) + assert len(t.starts()) == 1 + t.type(f"tr {goal_row(met=False)}\r") # 判定が止めを拒んだ(応答は続く) + t.wait_start(2) + t.type("/exit\r") + t.finish() + + +def test_goal_pending_reads_attachment_rows(mod, tmp_path): + tp = tmp_path / "t.jsonl" + written = time.time() + old = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(written - 60)) + new = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(written + 1)) + tp.write_text('{"type": "user"}\n') + assert not mod.goal_pending(str(tp), written) # 目標の無い会話 + tp.write_text(goal_row(sentinel=True, at=old) + "\n") + assert mod.goal_pending(str(tp), written) + tp.write_text(goal_row(sentinel=True, at=old) + "\n" + goal_row(met=False, at=old) + "\n") + assert mod.goal_pending(str(tp), written) # 判定が印より前 + tp.write_text(goal_row(sentinel=True, at=old) + "\n" + goal_row(met=False, at=new) + "\n") + assert not mod.goal_pending(str(tp), written) + tp.write_text(goal_row(sentinel=True, at=old) + "\n" + goal_row(met=True, at=old) + "\n") + assert not mod.goal_pending(str(tp), written) # 目標は終わっている + + +# ---------------------------------------------------------------- install(AC21)。一時の HOME だけで動かす + + +def install(tmp_path, relay=RELAY, **env): + e = isolated_env(tmp_path, **{"SHELL": "/bin/bash", **env}) + return subprocess.run([sys.executable, str(relay), "install"], capture_output=True, + text=True, env=e, timeout=20) + + +def messages(proc): + assert proc.returncode == 0, proc.stderr + assert proc.stderr == "" + return [json.loads(x)["systemMessage"] for x in proc.stdout.splitlines()] + + +def home_of(tmp_path): + return tmp_path / "home" + + +def test_install_first_then_idempotent(tmp_path): + home = home_of(tmp_path) + home.mkdir() + rc = home / ".bashrc" + rc.write_text("export A=1\n") + msgs = messages(install(tmp_path)) + assert len(msgs) == 1 and msgs[0].startswith(f"ndf-relay: {rc} へ alias claude を足した") + copy = home / ".local" / "share" / "ndf" / "relay.py" + assert copy.read_bytes() == RELAY.read_bytes() + assert copy.stat().st_mode & 0o777 == 0o755 + body = rc.read_text() + assert body.startswith("export A=1\n") + assert body.count("# >>> ndf relay >>>") == 1 and "# <<< ndf relay <<<" in body + assert """alias claude='python3 "${XDG_DATA_HOME:-$HOME/.local/share}/ndf/relay.py" run'""" in body + backups = list(home.glob(".bashrc.ndf-bak-*")) + assert len(backups) == 1 and backups[0].read_text() == "export A=1\n" + state = home / ".local" / "state" / "ndf" / "relay" + assert (state / "rc-added").read_text().splitlines() == [str(rc)] + before = {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in (rc, copy)} + assert messages(install(tmp_path)) == [] + assert {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in (rc, copy)} == before + # 利用者が囲みを消したら足し直さない + rc.write_text("export A=1\n") + assert messages(install(tmp_path)) == [] + assert rc.read_text() == "export A=1\n" + + +def test_install_alias_sources_real_file(tmp_path): + """足した alias は bash で読み込め、安定した場所の中継を指す。""" + home = home_of(tmp_path) + home.mkdir() + messages(install(tmp_path)) + e = isolated_env(tmp_path) + p = subprocess.run(["bash", "-c", f"shopt -s expand_aliases; source {home}/.bashrc; alias claude"], + capture_output=True, text=True, env=e, timeout=20) + assert p.returncode == 0, p.stderr + assert 'ndf/relay.py" run' in p.stdout + + +@pytest.mark.parametrize("definition", ["alias claude='x'", "claude () { :; }", "function claude { :; }", + "claude() { :; }", "function claude() { :; }"]) +def test_install_existing_definition_skips(tmp_path, definition): + home = home_of(tmp_path) + home.mkdir() + rc = home / ".bashrc" + rc.write_text(definition + "\n") + msgs = messages(install(tmp_path)) + assert len(msgs) == 1 and "claude の定義があるため alias を足さない" in msgs[0] + assert messages(install(tmp_path)) == [] + assert rc.read_text() == definition + "\n" + assert not list(home.glob(".bashrc.ndf-bak-*")) + + +def test_install_bash_aliases_definition_skips(tmp_path): + home = home_of(tmp_path) + home.mkdir() + (home / ".bash_aliases").write_text("alias claude=foo\n") + assert len(messages(install(tmp_path))) == 1 + assert not (home / ".bashrc").exists() + + +def test_install_zsh_uses_zdotdir(tmp_path): + home = home_of(tmp_path) + home.mkdir() + zd = tmp_path / "zd" + zd.mkdir() + msgs = messages(install(tmp_path, SHELL="/usr/bin/zsh", ZDOTDIR=zd)) + assert len(msgs) == 1 + assert "# >>> ndf relay >>>" in (zd / ".zshrc").read_text() + assert not (home / ".zshrc").exists() + + +def test_install_other_shell_only_copies(tmp_path): + home = home_of(tmp_path) + home.mkdir() + assert messages(install(tmp_path, SHELL="/usr/bin/fish")) == [] + assert (home / ".local" / "share" / "ndf" / "relay.py").exists() + assert not (home / ".bashrc").exists() + + +def test_install_disabled(tmp_path): + home = home_of(tmp_path) + home.mkdir() + assert messages(install(tmp_path, NDF_RELAY_AUTO=0)) == [] + assert list(home.iterdir()) == [] + + +def test_install_replaces_changed_copy(tmp_path): + home = home_of(tmp_path) + home.mkdir() + messages(install(tmp_path)) + newer = tmp_path / "newer" / "relay.py" + newer.parent.mkdir() + newer.write_bytes(RELAY.read_bytes() + "\n# 新しい版\n".encode()) + messages(install(tmp_path, relay=newer)) + assert (home / ".local" / "share" / "ndf" / "relay.py").read_bytes() == newer.read_bytes() + + +def test_install_unwritable_exits_zero(tmp_path): + home = home_of(tmp_path) + home.mkdir() + (home / ".local").write_text("ファイルなのでディレクトリを作れない") + assert messages(install(tmp_path)) == [] + + +def test_install_concurrent_once(tmp_path): + home = home_of(tmp_path) + home.mkdir() + (home / ".bashrc").write_text("x=1\n") + e = isolated_env(tmp_path, SHELL="/bin/bash") + procs = [subprocess.Popen([sys.executable, str(RELAY), "install"], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, env=e) for _ in range(4)] + outs = [p.communicate(timeout=20) for p in procs] + assert all(p.returncode == 0 for p in procs) + assert sum(len(o.splitlines()) for o, _ in outs) == 1 + assert (home / ".bashrc").read_text().count("# >>> ndf relay >>>") == 1 + assert len(list(home.glob(".bashrc.ndf-bak-*"))) == 1 + + +def test_install_respects_lock(tmp_path): + """install.lock を他が持っているあいだは 2 秒まで待ち、取れなければ何もしない。""" + home = home_of(tmp_path) + state = home / ".local" / "state" / "ndf" / "relay" + state.mkdir(parents=True) + with open(state / "install.lock", "a") as f: + fcntl.flock(f, fcntl.LOCK_EX) + t0 = time.time() + assert messages(install(tmp_path)) == [] + assert time.time() - t0 >= 1.5 + assert not (home / ".bashrc").exists() diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index cb5c1d0cf..74ec30918 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -727,3 +727,60 @@ def test_readme_runtime_table(): assert "token-guard.sh" in text for rt in ("Claude Code", "Codex", "Kiro", "agy"): assert rt in text + + +# ---------------------------------------------------------------- 中継の下の conductor(#895 AC23) + +import fcntl # noqa: E402 + + +@pytest.fixture() +def relay_dir(tmp_path): + """動いている中継に見立てた作業ディレクトリ。このテストのプロセスを中継の直接の子に見立てる。 + + hook(bash)→ relay.py is-child と起こされるので、親をたどって最初に当たるのは + このテストのプロセスになる。 + """ + d = tmp_path / "relay" + d.mkdir(mode=0o700) + lock = open(d / "relay.lock", "a") + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + (d / "child.pid").write_text(str(os.getpid())) + yield d + lock.close() + + +def test_context_under_relay_keeps_denying(tmp_path, state, relay_dir): + tp = transcript(tmp_path, 250_000) + env = {"NDF_RELAY_DIR": str(relay_dir)} + first = denied(run(agent(tp), state, env)) + second = denied(run(agent(tp), state, env)) + assert first and second + assert "ndf-next" in second + assert "supervisor の報告を待" in second + assert "/goal /ndf:development-workflow #829 #830" in second + + +def test_context_relay_not_direct_child_passes_once(tmp_path, state, relay_dir): + (relay_dir / "child.pid").write_text("1") + tp = transcript(tmp_path, 250_000) + env = {"NDF_RELAY_DIR": str(relay_dir)} + assert denied(run(agent(tp), state, env)) + assert denied(run(agent(tp), state, env)) is None + + +def test_context_relay_not_running_passes_once(tmp_path, state): + d = tmp_path / "relay" + d.mkdir() + (d / "relay.lock").touch() + (d / "child.pid").write_text(str(os.getpid())) + tp = transcript(tmp_path, 250_000) + env = {"NDF_RELAY_DIR": str(d)} + assert denied(run(agent(tp), state, env)) + assert denied(run(agent(tp), state, env)) is None + + +def test_context_reason_asks_for_ndf_next_block(tmp_path, state): + tp = transcript(tmp_path, 250_000) + reason = denied(run(skill(tp), state)) + assert "ndf-next" in reason diff --git a/plugins/ndf/scripts/token-guard.sh b/plugins/ndf/scripts/token-guard.sh index f9e597d51..a505a708a 100755 --- a/plugins/ndf/scripts/token-guard.sh +++ b/plugins/ndf/scripts/token-guard.sh @@ -154,7 +154,14 @@ guard_context() { dir=$(guards_dir) || exit 0 take_lock "$dir" "$sid" || exit 0 mark="$dir/context-$sid.json" - if [ "$(jq -r '.key // empty' "$mark" 2>/dev/null)" = "$key" ]; then + # 中継(relay.py)の直接の子の conductor では 1 度の通しをやめ、上限を超えている限り止め + # 続ける。人が居ない前提で LLM が「続ける」と決めて上限を超えたまま進むことを止める(#895) + relayed=0 + if [ -n "${NDF_RELAY_DIR:-}" ] && [ "$total" -gt "$limit" ] && command -v python3 >/dev/null 2>&1 \ + && python3 "$HERE/relay.py" is-child >/dev/null 2>&1; then + relayed=1 + fi + if [ "$relayed" = 0 ] && [ "$(jq -r '.key // empty' "$mark" 2>/dev/null)" = "$key" ]; then rm -f "$mark" 2>/dev/null exit 0 fi @@ -165,7 +172,11 @@ guard_context() { issues=$(printf '%s\n' "$words" | sed -E 's/[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}//g; s/[0-9]+(\.[0-9]+)+//g' | grep -oE '(^|[^0-9A-Za-z_/])#?[0-9]+\b' \ | grep -oE '[0-9]+' | sed 's/^/#/' | tr '\n' ' ') issues=${issues% } - deny "会話の文脈が ${total} トークンで、上限 ${limit} を超えた。この工程は新しい会話で始める。利用者へ次の 1 行を示して応答を終える: /ndf:development-workflow ${issues:-<課題番号>}(3 層で進めているなら、新しい会話で /goal に同じ 1 行を渡す)。<課題番号> のままなら、進めている課題の番号を補って示す。このまま続けると利用者が決めたら、同じ起動をもう一度行うと 1 度だけ通る。規約: ${CONTEXT_DOC}(止めるなら NDF_CONTEXT_GUARD=0、上限は NDF_CONTEXT_LIMIT)" + local next="/ndf:development-workflow ${issues:-<課題番号>}" + if [ "$relayed" = 1 ]; then + deny "会話の文脈が ${total} トークンで、上限 ${limit} を超えた。中継の下なので、上限を超えている限りこの起動を止め続ける。新しい持ち場を起動せず、動いている supervisor の報告を待ってから、引継ぎ文書(/goal の指示が名指ししたもの。無ければ書かない)を更新し、次のコマンドを情報文字列 ndf-next の囲みのコードブロック 1 つで出して応答を終える(中身: /goal ${next}、名指しの引継ぎ文書があれば「<文書> の続きから」)。<課題番号> のままなら、進めている課題の番号を補う。中継がそのブロックで次の区間を起動する。規約: ${CONTEXT_DOC}(止めるなら NDF_CONTEXT_GUARD=0、上限は NDF_CONTEXT_LIMIT)" + fi + deny "会話の文脈が ${total} トークンで、上限 ${limit} を超えた。この工程は新しい会話で始める。次のコマンドを情報文字列 ndf-next の囲みのコードブロック 1 つで示して応答を終える。中身: ${next}(3 層で進めているなら /goal ${next})。<課題番号> のままなら、進めている課題の番号を補って示す。このまま続けると利用者が決めたら、同じ起動をもう一度行うと 1 度だけ通る。規約: ${CONTEXT_DOC}(止めるなら NDF_CONTEXT_GUARD=0、上限は NDF_CONTEXT_LIMIT)" } case "$TOOL" in diff --git a/plugins/ndf/skills/development-workflow/SKILL.md b/plugins/ndf/skills/development-workflow/SKILL.md index 9e2a15fb3..2de8f24ca 100644 --- a/plugins/ndf/skills/development-workflow/SKILL.md +++ b/plugins/ndf/skills/development-workflow/SKILL.md @@ -203,7 +203,8 @@ mode: standard [references/context-window.md](references/context-window.md) にある。 **conductor は、`context-window.md` の 4 つの切れ目で、次の工程を始める引き継ぎの 1 行 -(`/ndf:development-workflow #<課題>`)を出す。** 3 層では conductor が `## 持ち場の報告` を +(`/ndf:development-workflow #<課題>`。3 層では先頭に `/goal `)を、情報文字列 `ndf-next` の +囲みのコードブロック 1 つで出す。** 3 層では conductor が `## 持ち場の報告` を 受け取った時点で出し、supervisor は出さない。持ち場の境がこの切れ目に当たるためである。 文脈量の hook(`token-guard.sh`)が起動を止めたときも出す。ただし報告が `結果: 関門` なら 受け取った時点では出さず、関門の承認と取り込み(設計 Pull Request のマージなど)の後に出す。 @@ -360,6 +361,11 @@ Pull Request のマージ、制作物承認は本番の提出先への操作に 絶対パスで書く。** supervisor はこの 1 行で進行を記録し、この Skill も `progress-tracking` も 起動しない(形とキーごとの打つ時点は `agent-layers.md` の「conductor → supervisor」)。 +**区間の切れ目の再起動は中継が自動で行う(Claude Code だけ)。** 利用者が `claude` と打つと +alias が中継を挟み、conductor が出した `ndf-next` のブロックを拾って、`/exit`・プラグインの更新・ +次の区間の起動を行う。中継が無い・止まったときは、今までどおり人がブロックの中身を貼り付ける。 +始め方・止め方・上限は [references/relay.md](references/relay.md) にある。 + **対話で `/ndf:development-workflow` を呼んだときは 3 層へ出さない。** 人がその場にいて 工程ごとに指示を変えられるため、進め方を変えない。 diff --git a/plugins/ndf/skills/development-workflow/references/context-window.md b/plugins/ndf/skills/development-workflow/references/context-window.md index b2dfb408f..5d67b65d2 100644 --- a/plugins/ndf/skills/development-workflow/references/context-window.md +++ b/plugins/ndf/skills/development-workflow/references/context-window.md @@ -94,27 +94,39 @@ worker の 3 層)は [agent-layers.md](agent-layers.md) が持つ。 - **止めるのは工程の切れ目ごとに 1 度である。** 止めた直後に、工程へ入る次の起動が同じもの (Skill なら名前と引数、Agent なら `description`)であれば 1 度だけ通す。**続けると決めたら、 同じ起動をもう一度行う。** 別の起動なら、上限を超えている限り再び止める -- 止めたときの理由の欄が、利用者へ示す 1 行(次の節)を持つ。conductor はその 1 行を示して - 応答を終える +- **中継([relay.md](relay.md))の直接の子の conductor では、1 度の通しをしない。** 上限を + 超えている限り止め続ける。人が居ない前提で、上限を超えたまま進むことを機械で止めるためである。 + conductor は新しい持ち場を起動せず、動いている supervisor の報告を待ってから、引継ぎ文書を + 更新し、次のコマンドのブロック(次の節)を出して応答を終える +- 止めたときの理由の欄が、利用者へ示すコマンド(次の節)を持つ。conductor はそれを次の節の + 形で示して応答を終える Codex / Kiro / agy には hook を置かない。4 つの切れ目で 1 行を出す規約だけで守る ([waiting.md](waiting.md) の「hook」節の表)。 ## 新しい会話で戻す -**conductor は、4 つの切れ目と hook に止められたときに、次の工程を始める 1 行を出す。** +**conductor は、4 つの切れ目と hook に止められたときに、次の工程を始めるコマンドを、 +情報文字列 `ndf-next` の囲みのコードブロック 1 つで出す。** -```text -/ndf:development-workflow #829 #830 +```ndf-next +/goal /ndf:development-workflow #829 #830 ``` -- **形は `development-workflow` を起動する 1 行である。** 工程 Skill はモードと作業ツリーを戻す - 手順を持たないため、入口から入り直す。Codex と Kiro では、それぞれの README が示す Skill の - 起動の書き方に読み替える +- **囲みの中身がそのまま次の区間の最初の入力になる。** 複数行でよい。最後の応答に + **1 つだけ**置く。2 つ以上置くと、中継は拾わない。説明のために引く例は 4 つのバッククォートの + 囲みの中へ入れる(外側の囲みの中は数えない) +- **中身は `development-workflow` を起動する 1 行である**(`/ndf:development-workflow #829 #830`)。 + 工程 Skill はモードと作業ツリーを戻す手順を持たないため、入口から入り直す。Codex と Kiro では、 + それぞれの README が示す Skill の起動の書き方に読み替える +- **3 層(`/goal`)で進めているときは、中身の先頭を `/goal ` にする** - **3 層では、conductor が `## 持ち場の報告` を受け取った時点で出す**(supervisor は出さない)。 持ち場の境が切れ目に当たるためである。ただし報告が `結果: 関門` なら、関門の承認と取り込み - (設計 Pull Request のマージなど)が済んだ後に出す。関門の前に会話を切らないためで、 - 切れ目 1 はこの形で満たす。新しい会話では `/goal` に同じ 1 行を渡す + (設計 Pull Request のマージなど)が済んだ後に出す。**関門の承認より前には出さない。** 出すと、 + 中継が関門の前で会話を切る。切れ目 1 はこの形で満たす +- **中継の有無によらず出す。** 中継([relay.md](relay.md))の下では、中継がブロックを拾って + 次の区間を自動で起動する。中継が無ければ、人がブロックの中身を新しい会話へ貼り付ける +- **引継ぎ文書の「次に実行するコマンド」の節も、同じブロックで書く。** 形の定義はこの節だけに置く **新しい会話の `development-workflow` は、次の順で状態を戻す。** diff --git a/plugins/ndf/skills/development-workflow/references/relay.md b/plugins/ndf/skills/development-workflow/references/relay.md new file mode 100644 index 000000000..cd007aff7 --- /dev/null +++ b/plugins/ndf/skills/development-workflow/references/relay.md @@ -0,0 +1,110 @@ +# 中継で区間の切れ目を自動にする(Claude Code だけ) + +**`/goal /ndf:development-workflow` の区間の切れ目で人が行っていた「`/exit`・起動し直し・ +次のコマンドの貼り付け」を、中継(`scripts/relay.py`)が行う。** 人が入力するのは関門の答えだけになる。 + +例: 設計の関門をまたいで実装へ進む。 + +1. 利用者がいつもどおり `claude` と打つ(alias が中継を挟む)。その中で `/goal /ndf:development-workflow #895` を入力する +2. conductor が設計の関門で `AskUserQuestion` を出し、利用者が「承認」と答える +3. conductor が設計 Pull Request をマージし、最後の応答に次のブロックを出して応答を終える + + ```ndf-next + /goal /ndf:development-workflow #895 + ``` + +4. 中継がそのブロックを拾い、claude へ `/exit` を入力して終え、プラグインを更新し、 + 区切りの 1 行(`── ndf-relay: 区間 2 ──`)を出して、同じ端末で `claude "<ブロックの中身>"` を起動する + +ブロックの形は [context-window.md](context-window.md) の「新しい会話で戻す」が定める。 + +## 始め方 + +**利用者の手作業は無い。** ndf の入った Claude Code を起動すると、SessionStart hook が +`relay.py install` を呼び、次の 2 つを行う。 + +| すること | 中身 | +| --- | --- | +| 中継を置き直す | `${XDG_DATA_HOME:-~/.local/share}/ndf/relay.py` へ写す。中身が同じなら書かない。更新の後の最初の起動で新しい版になる | +| alias を 1 度だけ足す | `$SHELL` が bash なら `~/.bashrc`、zsh なら `${ZDOTDIR:-~}/.zshrc` の末尾へ、印のついた囲み(`# >>> ndf relay >>>` 〜 `# <<< ndf relay <<<`)で `alias claude='python3 "${XDG_DATA_HOME:-$HOME/.local/share}/ndf/relay.py" run'` を足す。書く前に `<設定>.ndf-bak-` へ写しを取る。足したときだけ 1 行で知らせる | + +- **次に開いたシェルから効く。** 今のシェルでは `source ~/.bashrc` で効く +- 既に `claude` の alias か関数がある(bash では `~/.bash_aliases` も見る)と足さず、1 度だけ案内する。中継を使うなら、案内の alias の 1 行を自分で置く +- **戻すには囲みを消す。** 消した後は足し直さない(`${XDG_STATE_HOME:-~/.local/state}/ndf/relay/rc-added` の記録で見分ける。足し直したいときはその行を消す) +- bash と zsh 以外のシェルでは足さない +- **`NDF_RELAY_AUTO=0` で `install` 全体が何もしなくなる** + +## 中継を挟まない起動 + +`claude` と打っても、次の起動は中継を挟まずに本物の claude をそのまま exec する(素通し)。 +振る舞いも終了コードも直接打ったのと同じである。 + +| 起動 | 例 | +| --- | --- | +| 非対話 | `claude -p ...`・`--help`・`--version` | +| 副命令 | `claude mcp ...`・`claude doctor` など | +| 端末でない | パイプ・リダイレクト | +| 中継の下 | conductor が Bash から起こす `claude -p` | +| 止めた | `NDF_RELAY=0 claude` | + +擬似端末を作れない環境(Windows)と、導入済みのプラグインの一覧(`plugin list --json`)から ndf の +名前と版を読めないときは、`ndf-relay: 中継を始めない(<理由>)…` の 1 行を出してから素通しする。 + +`/goal` を使わない普段の利用では、印が書かれないので中継は何もせず、claude を終えると同じ +終了コードでシェルへ戻る。 + +## 止め方 + +| 手段 | 効き目 | +| --- | --- | +| `python3 ~/.local/share/ndf/relay.py stop`(別の端末から) | 動いている中継すべてに停止の印を置く。次の印を受けても `/exit` を入力しない。動いている中継が無ければ終了コード 1 | +| `touch <作業ディレクトリ>/stop` | 同じ(1 つの中継だけ) | +| 区間の中で `/exit` か Ctrl-C を 2 回 | 中継も次の区間を起動せずに終わる | + +作業ディレクトリは `${XDG_STATE_HOME:-~/.local/state}/ndf/relay/<時刻>--<乱数>/` で、 +起動ごとに新しく作る。区間の中では環境変数 `NDF_RELAY_DIR` が指す。 + +## 上限 + +| 上限 | 既定 | 変え方 | 超えたとき | +| --- | --- | --- | --- | +| 1 日の起動回数(全部の中継の合計) | 20 | `NDF_RELAY_MAX_STARTS` | 次の区間を起動しない | +| 空回り(区間が 3 つ続けて、起動から 120 秒未満で切れ目に達した) | — | — | 3 つ目の切れ目で次の区間を起動しない | +| 静まり(印・会話の記録・利用者の入力が動かない秒数) | 15 | `NDF_RELAY_QUIET` | この秒数がたつまで `/exit` を入力しない。`/goal` の目標がある区間では、印の後の目標の判定の記録も待つ | + +**文脈の上限(`NDF_CONTEXT_LIMIT`、既定 200,000)は中継の下で強くなる。** 中継の直接の子の +conductor では、文脈量の hook が工程へ入る起動を 1 度の通しなしに止め続ける。conductor は動いて +いる supervisor の報告を待ってから、引継ぎ文書を更新し、ブロックを出して終える +([context-window.md](context-window.md) の「上限を超えたら hook が止める」)。背景の処理 +(supervisor を含む)が動いているあいだの応答では印を書かない。 + +## 落ちたときの続け方 + +中継が次の区間を起動しないと決めたときは、`ndf-relay:` で始まる 1 行が画面に出る。 + +| 1 行 | 状態 | 続け方 | +| --- | --- | --- | +| `次の区間を起動しない(<理由>)。このまま続けるか、/exit して示されたコマンドを手で入力する` | 上限・空回り・停止の印。今の区間は動いたまま | そのまま続けるか、`/exit` してから conductor のブロックの中身で `claude` を起動する | +| `次の区間を起動できない(<理由>)。次のコマンド:` の後に中身 | 更新か起動に失敗し、中継は終わった(終了コード 2) | 表示された中身で `claude` を起動する | + +## 区間をまたいで設定を保つ + +**2 つ目以降の区間は、ブロックの中身だけで起動する。** 最初の `claude` に付けた引数 +(`--model`・`--resume`・`-c` など)は引き継がない。捨てた会話へ戻らないためである。 +モデルなどを保つときは、設定か環境変数(`ANTHROPIC_MODEL` など)で与える。 + +## 記録の読み方 + +作業ディレクトリの `log.jsonl` に 1 行ずつ残る。 + +| `event` | いつ | 主なキー | +| --- | --- | --- | +| `start` | 区間を起動した | `section`・`pid`・`command`・`from_session`・`plugin_version`(起動の直前に読んだ版)・`cwd`(印の作業ディレクトリが消えていたら `cwd_fallback` に元の値) | +| `end` | 区間が終わった | `seconds`(起動から印まで。印なしなら終わりまで)・`ended_by`(`mark` / `no-mark` / `sigterm` / `sigkill`) | +| `stop` | 次の区間を起動しないと決めた | `reason`(`stop-file` / `max-starts` / `spin` / `update-failed` / `start-failed` / `error`) | + +```bash +cat ~/.local/state/ndf/relay/*/log.jsonl | jq -c 'select(.event == "stop")' +``` + +設計と決定の理由は ai-plugins の課題 #895 の設計文書にある。 From 90054b095fc56fc27c244de83bb5dc3a643a71f5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 13:53:50 +0000 Subject: [PATCH 2/4] =?UTF-8?q?Fix:=20#895=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98:=20=E8=AA=AD=E3=82=81=E3=81=AA?= =?UTF-8?q?=E3=81=84=20claude=20=E3=82=92=E4=B8=AD=E7=B6=99=E3=81=A8?= =?UTF-8?q?=E8=A6=8B=E3=81=AA=E3=81=95=E3=81=9A=E3=80=81relayed=20?= =?UTF-8?q?=E3=82=92=20local=20=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - relay.py の _is_self は開けないファイルで False を返す。設計(先頭 4 KB に relay.py を 含むものだけを飛ばす)に合わせ、実行だけできる正規の claude を飛ばして 127 になるのを防ぐ。 飛ばし損ねた繰り返しは NDF_RELAY_DEPTH が止める - token-guard.sh の guard_context で relayed を local の宣言に加える Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- plugins/ndf/scripts/relay.py | 3 ++- plugins/ndf/scripts/tests/test_relay.py | 11 +++++++++++ plugins/ndf/scripts/token-guard.sh | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/relay.py b/plugins/ndf/scripts/relay.py index eb8ffa1c9..0be55a0cd 100755 --- a/plugins/ndf/scripts/relay.py +++ b/plugins/ndf/scripts/relay.py @@ -257,7 +257,8 @@ def _is_self(path: str) -> bool: with open(real, "rb") as f: return b"relay.py" in f.read(4096) except OSError: - return True + # 読めないものは中継と見なさない。飛ばし損ねた繰り返しは NDF_RELAY_DEPTH が止める + return False def resolve_claude() -> str | None: diff --git a/plugins/ndf/scripts/tests/test_relay.py b/plugins/ndf/scripts/tests/test_relay.py index 2c5f41602..2b051299b 100644 --- a/plugins/ndf/scripts/tests/test_relay.py +++ b/plugins/ndf/scripts/tests/test_relay.py @@ -597,6 +597,17 @@ def test_resolve_skips_wrappers(mod, tmp_path, monkeypatch): assert mod.resolve_claude() == str(forced) +def test_resolve_keeps_unreadable_claude(mod, tmp_path, monkeypatch): + """読めない(実行だけできる)claude は中継と見なさずに選ぶ。飛ばし損ねは深さの変数が止める。""" + real = real_claude(tmp_path) + real.chmod(0o111) + if os.access(real, os.R_OK): + pytest.skip("読み取り権限を外せない(root で実行している)") + monkeypatch.setenv("PATH", os.pathsep.join([str(real.parent), "/usr/bin"])) + monkeypatch.delenv("NDF_RELAY_CLAUDE", raising=False) + assert mod.resolve_claude() == str(real) + + def test_run_nested_stops_127(tmp_path): e = isolated_env(tmp_path, NDF_RELAY_DEPTH=2, NDF_RELAY_CLAUDE=real_claude(tmp_path)) p = subprocess.run([sys.executable, str(RELAY), "run"], capture_output=True, text=True, diff --git a/plugins/ndf/scripts/token-guard.sh b/plugins/ndf/scripts/token-guard.sh index a505a708a..15b053e83 100755 --- a/plugins/ndf/scripts/token-guard.sh +++ b/plugins/ndf/scripts/token-guard.sh @@ -131,7 +131,7 @@ guard_context() { # サブエージェントの中の起動は見ない。agent_id はサブエージェントの中でだけ付く # (Claude Code 2.1.280 で実測。サブエージェントの transcript_path は親の記録を指す) [ -n "$(field '.agent_id')" ] && exit 0 - local tp sid key words total limit dir mark issues skill + local tp sid key words total limit dir mark issues skill relayed tp=$(field '.transcript_path') case "$tp" in */subagents/*) exit 0 ;; esac sid=$(field '.session_id') From fd7a2a4c061f63797d0aef6de9793f17064a7ccf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 14:07:53 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Test:=20#895=20=E8=B5=B7=E5=8B=95=E3=81=AE?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E5=90=88=E3=81=84=E3=81=AE=E8=A9=A6=E9=A8=93?= =?UTF-8?q?=E3=82=92=E3=80=81=E4=B8=A1=E6=96=B9=E3=81=AE=E4=B8=AD=E7=B6=99?= =?UTF-8?q?=E3=81=8C=E6=B1=BA=E3=81=BE=E3=82=8B=E3=81=BE=E3=81=A7=E5=BE=85?= =?UTF-8?q?=E3=81=A4=E5=BD=A2=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HOME を共有する 2 つの中継では t.rows() が相手の記録も含むため、負けた側の stop の行を勝った側も拾って待ちを抜けていた。勝った側の子が starts.jsonl へ 書く前に抜けると [1, 1] になる(CI の run 35870357441)。自分の中継の記録だけを 見て、両方が「2 つ目の起動」か「stop」に決まるまで待つ。 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- plugins/ndf/scripts/tests/test_relay.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/plugins/ndf/scripts/tests/test_relay.py b/plugins/ndf/scripts/tests/test_relay.py index 2b051299b..cfd01eb53 100644 --- a/plugins/ndf/scripts/tests/test_relay.py +++ b/plugins/ndf/scripts/tests/test_relay.py @@ -696,13 +696,21 @@ def test_run_max_starts_race_one_wins(tmp_path): t.wait_start(1) for t in ts: t.type("mark next\r") - for t in ts: - t.wait(lambda t=t: any(r["event"] in ("stop",) for r in t.rows()) - or len(t.starts()) >= 2, what="どちらかに決まる") + # HOME を共有するので t.rows() は両方の記録を含む。自分の中継の記録だけを見る。 + # 負けた側の stop は勝った側の start の記録の後に出るが、勝った側の子が + # starts.jsonl へ書くのはさらに後になりうる。両方が決まるまで待つ + def own(t): + p = pathlib.Path(t.starts()[0]["relay_dir"]) / "log.jsonl" + return [json.loads(x) for x in p.read_text().splitlines()] + + def decided(t): + return len(t.starts()) >= 2 or events(own(t), "stop") + + ts[0].wait(lambda: all(decided(t) for t in ts), what="両方が決まる") started = sorted(len(t.starts()) for t in ts) - assert started == [1, 2] + assert started == [1, 2], [own(t) for t in ts] loser = next(t for t in ts if len(t.starts()) == 1) - assert [r["reason"] for r in events(loser.rows(), "stop")] == ["max-starts"] + assert [r["reason"] for r in events(own(loser), "stop")] == ["max-starts"] finally: for t in ts: t.close() From 256829ed85208746493059e4fb92b7f7804711ce Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 14:16:58 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Docs:=20#895=20=E3=81=AE=E8=A6=81=E6=B1=82?= =?UTF-8?q?=E3=83=BB=E8=A8=AD=E8=A8=88=E3=83=BB=E6=B1=BA=E5=AE=9A=E3=83=BB?= =?UTF-8?q?=E8=A8=88=E7=94=BB=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98?= =?UTF-8?q?=E3=81=B8=E3=81=BE=E3=81=A8=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 要求・設計・決定の記録・実装計画の 4 本を確定仕様へ畳み、元の 4 本は issues/old/milestone-26-relay-restart/ へ退避した。中継は副命令 5 つ・状態のファイル 6 つ・ 記録の形・install の 7 段を持つ独立した機能のため、新しい仕様書を 1 本作った。 - ndf-relay-segment-restart.md(新設): 用語、構成要素、決定と理由(決定 1〜25 の結論と理由)、 常に成り立つ条件、副命令の契約、素通しの条件、本物の claude の解決、中継の状態遷移と各段、 上限と止め方、mark の判定、目標の判定の記録、文脈の上限で切る、install の I1〜I7、 作業ディレクトリ・印・記録・環境変数、前提にした Claude Code の振る舞い、テスト観点 (受け入れ条件の言い換え、通しの確かめの結果、実機で確かめていない 3 点) - ndf-token-waits-and-context-cut.md: 文脈量の判定に「中継の直接の子では 1 度の通しをしない」、 引き継ぎの 1 行を ndf-next のブロックで出すこと、用語・hook の出力・運用・テスト観点・関連リンク - docs/specifications/README.md に 1 行、issues/old/README.md に 1 行 - relay.md の末尾の参照を確定仕様のパスへ向けた。元の 4 本を指す参照は退避先の外に無かった Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- docs/specifications/README.md | 1 + .../ndf-relay-segment-restart.md | 481 ++++++++++++++++++ .../ndf-token-waits-and-context-cut.md | 20 +- issues/old/README.md | 1 + .../issue-895-design-decisions.md | 0 .../issue-895-design.md | 0 .../issue-895-plan.md | 0 .../issue-895-requirements.md | 0 .../development-workflow/references/relay.md | 2 +- 9 files changed, 500 insertions(+), 5 deletions(-) create mode 100644 docs/specifications/ndf-relay-segment-restart.md rename issues/{ => old/milestone-26-relay-restart}/issue-895-design-decisions.md (100%) rename issues/{ => old/milestone-26-relay-restart}/issue-895-design.md (100%) rename issues/{ => old/milestone-26-relay-restart}/issue-895-plan.md (100%) rename issues/{ => old/milestone-26-relay-restart}/issue-895-requirements.md (100%) diff --git a/docs/specifications/README.md b/docs/specifications/README.md index b8230c045..5a468ed92 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -29,5 +29,6 @@ | [test-monitor-env-isolation.md](test-monitor-env-isolation.md) | テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)をリポジトリの根の共通の前提で外すこと、外す時点と戻す時点、根の設定ファイルで基準のディレクトリを固定すること | | [ndf-token-waits-and-context-cut.md](ndf-token-waits-and-context-cut.md) | 待つ間の問い合わせ(前景の `sleep` の待ち・変わらないファイルの読み直し)と、文脈が上限を超えた conductor の工程の起動を止める hook(`token-guard.sh`)の判定・記録の形・入出力の契約、引き継ぎの 1 行、4 ランタイムの扱い。規約は `development-workflow` の `references/waiting.md` と `context-window.md` が正 | | [ndf-worker-agent-and-skill-excerpts.md](ndf-worker-agent-and-skill-excerpts.md) | サブエージェントに Skill 本文を読ませない仕組み。記録のコマンド 1 行(`projects-sync.sh` が issue の本文と盤面へ同時に残す)の契約、worker のエージェント定義 `ndf:worker`(Skill と Agent のツールを外す)、抜粋の形と上限、仕事を分ける器と小さな作業の線引き。規約は `development-workflow` の `references/agent-layers.md` / `work-vessels.md` と `skills/EXCERPTS.md` が正 | +| [ndf-relay-segment-restart.md](ndf-relay-segment-restart.md) | 区間の切れ目で claude を起動し直す中継(`relay.py`)。`ndf-next` のブロックを印へ写す Stop hook、擬似端末の子としての起動と素通しの条件、静まり・上限・空回り・停止の印、作業ディレクトリと記録の形、alias を 1 度だけ足す `install`、中継の下で文脈量の拒否を止め続けること。利用者向けの案内は `development-workflow` の `references/relay.md`、次のコマンドの形は `context-window.md` が正 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-relay-segment-restart.md b/docs/specifications/ndf-relay-segment-restart.md new file mode 100644 index 000000000..139470ba7 --- /dev/null +++ b/docs/specifications/ndf-relay-segment-restart.md @@ -0,0 +1,481 @@ +# 区間の切れ目で claude を起動し直す中継 + +`/goal /ndf:development-workflow` の区間の切れ目で人が行っていた「`/exit`・起動し直し・次の +コマンドの貼り付け」を、端末の前景に常駐する中継(`plugins/ndf/scripts/relay.py`)が行う。 +人が入力するのは関門の答えだけになる。中継が動けないとき・止まると決めたときは、`ndf-relay:` の +1 行を出して、人がコマンドを貼り付ける今までどおりの運用へ落ちる。Claude Code だけが対象である。 +この文書は、中継の入出力の契約・状態の置き場所・判定の条件と、それぞれをそう決めた理由を残す。 + +**利用者向けの始め方・止め方・上限・記録の読み方と、次のコマンドの形は Skill の文書が正である。** + +| 何を読むか | 正本 | +| --- | --- | +| 次のコマンドを出す形(`ndf-next` のブロック)、出す時点、引継ぎ文書の「次に実行するコマンド」 | `plugins/ndf/skills/development-workflow/references/context-window.md` の「新しい会話で戻す」 | +| 中継の始め方・中継を挟まない起動・止め方・上限・落ちたときの続け方・記録の読み方 | `plugins/ndf/skills/development-workflow/references/relay.md` | +| 文脈量の hook の判定(上限・文脈量の読み方・工程へ入る起動の見分け方) | [ndf-token-waits-and-context-cut.md](ndf-token-waits-and-context-cut.md) の「文脈量の判定」 | +| 3 層(conductor / supervisor / worker)の運転 | [ndf-agent-layers-unattended-run.md](ndf-agent-layers-unattended-run.md) | + +## 概要 + +**例: 設計の関門をまたいで実装へ進む。** 利用者は VS Code の統合ターミナル(tmux の中でも外でも +よい)で作業している。 + +| 順 | 誰が | 何をする | +| ---: | --- | --- | +| 1 | SessionStart hook | `relay.py install` が中継を安定した場所へ置き、`~/.bashrc`(zsh なら `~/.zshrc`)へ印のついた囲みで `alias claude=...` を 1 度だけ足す。次に開いたシェルから、`claude` と打つと中継を挟む | +| 2 | 利用者 | `claude` と打ち、起動した claude の中で `/goal /ndf:development-workflow #895` を入力する | +| 3 | conductor(区間 1) | 設計の関門で `AskUserQuestion` を出す。答えを待つあいだ Stop は起きず、印は書かれない | +| 4 | 利用者 | 「承認」と答える(キー入力は中継を通ってそのまま子へ届く) | +| 5 | conductor | 設計 Pull Request をマージし、最後の応答に `ndf-next` のブロック(中身 `/goal /ndf:development-workflow #895`)を出して応答を終える | +| 6 | Stop hook | `relay.py mark` がブロックの中身を印 `next.json` へ写す | +| 7 | 中継 | 印・会話の記録・利用者の入力が 15 秒動かないのを見て、子の端末へ `/exit` と改行を入力し、子が終わるのを確かめる | +| 8 | 中継 | プラグインを更新して版を読み、区切りの 1 行(`── ndf-relay: 区間 2 ──`)を出して、同じ端末で `claude "<ブロックの中身>"` を子として起動する | +| 9 | conductor(区間 2) | 新しい版の hook と Skill で、実装の持ち場から始める | + +**`/goal` を使わない普段の利用では印が書かれず、中継は何もしないまま claude と同じ終了コードで +終わる。** 中継を使わない利用者と Codex / Kiro / agy では、人がブロックの中身を貼り付ける。 + +**中継が使えない・止まると決めたときは、今までどおりに落ちる。** + +| 場面 | 画面に出るもの | 人がすること | +| --- | --- | --- | +| 対話でない起動(`-p`・パイプ・副命令・`--help` など)か、中継の下で打たれた | 何も出さず、本物の claude をそのまま exec する(素通し) | 何もしない | +| 対話だが中継を始められない(擬似端末・プラグインの名前と版・作業ディレクトリ) | `ndf-relay: 中継を始めない(<理由>)。切れ目では示されたコマンドを手で入力する` を出してから素通し | 切れ目で `/exit` し、ブロックの中身を貼り付ける | +| 切れ目で停止の印・上限・空回り・中継の中の例外 | `ndf-relay: 次の区間を起動しない(<理由>)。このまま続けるか、/exit して示されたコマンドを手で入力する` | 同上(今の区間は動いたまま) | +| 切れ目で更新・起動に失敗 | `ndf-relay: 次の区間を起動できない(<理由>)。次のコマンド:` と中身。中継は終了コード 2 で終わる | 表示された中身で claude を起動する | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 区間 | 1 つの Claude Code のプロセス(conductor の会話)が受け持つ範囲。切れ目から次の切れ目まで | +| 切れ目 | `context-window.md` の切ってよい 4 点、関門の承認と取り込みの後、文脈量の hook が工程へ入る起動を止めた後 | +| 中継 | 端末の前景に常駐し、claude を擬似端末の子として起動して、印を見て区間を切り替えるプロセス(`relay.py run`) | +| 印 | Stop hook が中継へ次の区間の開始を知らせるファイル(`next.json`) | +| 停止の印 | 中継に次の区間を起動させないために置く空のファイル(`stop`) | +| 静まり | 印・会話の記録・利用者の入力が決まった秒数動かないこと。中継はこれを待ってから `/exit` を入力する | +| 空回り | 印を書いて終わった区間が短い時間で続くこと。進まずに起動だけが重なる状態 | +| 素通し | 中継を挟まず、本物の claude を引数のまま exec すること | +| 落ちる | 中継が区間を切り替えず、人が次のコマンドを入力する運用に戻ること | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| `plugins/ndf/scripts/relay.py` | 中継の本体。副命令 `run` / `stop` / `mark` / `install` と、文脈量の hook が使う内部の `is-child` を持つ。標準ライブラリだけで書く | +| `plugins/ndf/hooks/claude.json` の `Stop` | `NDF_RELAY_DIR` があるときだけ `python3 /scripts/relay.py mark` を呼ぶ(既存の Slack 通知の後、`timeout` 5 秒、`continueOnError: true`)。無ければ `python3` を起こさない | +| `plugins/ndf/hooks/claude.json` の `SessionStart`(`matcher: startup`) | `python3 /scripts/relay.py install` を既存の 3 件の後に呼ぶ(`timeout` 5 秒、`continueOnError: true`) | +| `plugins/ndf/scripts/token-guard.sh` | 文脈量の判定で、中継の直接の子の conductor なら 1 度の通しをせずに止め続ける(下の「文脈の上限で切る」) | +| `development-workflow/references/context-window.md` / `relay.md` / `SKILL.md` | 次のコマンドの形・中継の案内・引き継ぎの規約 | + +```mermaid +graph TB + subgraph term["利用者の端末"] + R["中継(relay.py run)
前景で常駐"] + subgraph pty["擬似端末"] + C["claude(区間 n)"] + end + end + H["Stop hook
relay.py mark"] + subgraph state["NDF_RELAY_DIR(0700)"] + P[relay.lock / relay.pid / child.pid] + M["next.json(印)"] + S["stop(停止の印)"] + L[log.jsonl] + end + R -->|キー入力・大きさ・/exit| C + C -->|画面の出力| R + C --> H + H -->|書く・消す| M + H -->|直接の子かを見る| P + R -->|待つ| M + R -->|見る| S + R -->|書く| L + R -->|plugin update| CP[claude plugin] +``` + +**Codex / Kiro / agy には中継も hook も置かない。** `hooks/codex.json` と `dev.agy/hooks.json` は +`mark` も `install` も呼ばず、Kiro は hook の定義を持たない。これらのランタイムでは、人が +`ndf-next` のブロックの中身を貼り付ける。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 中継は端末の前景に常駐し、claude を擬似端末の子として起動する形だけにする。tmux を前提にしない | 前提が端末と Python 3 だけになり、tmux や VS Code の設定を確かめて入れる処理が要らない。2 つの形を持つと使われない側が古くなる。tmux の中で使うなら、ペインの中で `claude` と打てば同じに動く | +| 始められない・止まると決めたときは、終了コード 1 で止めずに今までどおりの運用へ落ちる | 止めると、利用者は前提をそろえるまで claude を起動できない。conductor は中継の有無によらずブロックを出すので、人は貼り付けて続けられる | +| 区間の終わりは、最後の応答の `ndf-next` のブロック 1 つで決める | Stop hook に `last_assistant_message` が来る。情報文字列を `text` にしないのは説明の例と取り違えないためで、外側の囲みの中も数えない。引継ぎ文書の見出しは置き場所も名前も決まっておらず、記録の `goal_status` は `/goal` の無い区間に現れない | +| 関門は区間の中で `AskUserQuestion` のまま受け、ブロックは承認と取り込みの後に出す | 答えを待つあいだは Stop が起きないので、中継は関門を知らなくてよい。画面の文言から関門を読むと、Claude Code の版で文言が変わったときに関門の前で切る | +| `stop_hook_active` を見ない。印は最新の Stop で置き換えるか消し、中継は静まってから動く | `/goal` の判定が止めを拒むと応答が続き、`stop_hook_active` が真の Stop こそ区間の最後でありうる。利用者の入力も見るのは、打っている途中に `/exit` を混ぜないためである | +| `/goal` の目標がある区間では、印より後の判定の記録を待つ | 判定は command hook より後に記録へ書かれる。判定が静まりの秒数より長く掛かっても、判定の前に `/exit` を入力しない | +| 前の区間は子の端末へ `/exit` を入力して終わらせる。30 秒で終わらなければ SIGTERM、さらに 10 秒で SIGKILL | `/exit` は人の終了と同じ終わり方(終了コード 0・SessionEnd の理由 `prompt_input_exit`)になる。Stop hook の `{"continue": false}` はプロセスを終わらせない。子が終わらないまま次を起動すると claude が 2 つ動く | +| 印を書くのは、中継が起動した子の claude だけにする | conductor が Bash から起こす `claude -p` も `NDF_RELAY_DIR` を継ぎ、Stop hook が走る。環境変数だけでは見分けられないため、親をたどって最初に当たる claude が `child.pid` と一致するかで見る | +| プラグインは切れ目ごとに毎回更新し、失敗したら次の区間を起動しない | 配布の直後かを判定する材料が無い。版が変わっていなければ更新は数秒で何も変えない。古い版で始めると、配布した hook と Skill で進んだと記録が誤って示す | +| 子が印なしで終われば中継も終わる。空回りは「3 つ続けて起動から 120 秒未満で印」で見る | 印の無い終わりは人の `/exit`・Ctrl-C の 2 回・落ちたのいずれかで、同じコマンドで起動し直しても意図に反するか同じく落ちる。同じコマンドの繰り返しでは見ない。入口のコマンドは区間が違っても同じ文字列になりうる | +| 1 日の起動回数の上限は 20、静まりは 15 秒を初期値にし、環境変数で変える | 1 日に 2〜3 のまとまりを進めても 20 には届かず、空回りの検出を抜けた暴走は 1 日で止まる | +| 中継と hook は Python の 1 ファイルにする | 印の形・作業ディレクトリ・親のたどりを共有し、片方だけが変わって食い違わない。擬似端末・端末の属性・JSON・引数の配列での起動を標準ライブラリだけで書ける。bash では擬似端末の入出力を中継できない | +| 次のコマンドはシェルを通さず、絶対パスと引数の配列で `os.execve` に渡す | 中身は LLM の出力で、引用符や `$(...)` を含みうる | +| 記録は区間ごとに `start` と `end` の 2 行に分ける | 版は起動の時点で、長さと終わり方は終わった後に分かる。1 行にまとめると、起動の後に落ちた区間の行が書かれないか、行を書き直すことになる | +| 次の区間の作業ディレクトリが消えていたら、主ディレクトリか在る最も近い親で起動する | 設計 Pull Request のマージで作業ツリーが消える切れ目は毎回起きうる。次の区間は「新しい会話で戻す」で作業ツリーを戻すので、主ディレクトリから始めて足りる | +| `alias claude=...` で常に中継を挟み、`run` の後ろはすべて claude の引数として解釈しない | 中継の設定を引数で受けると claude の引数と名前がぶつかる。設定は環境変数(`NDF_RELAY_*`)だけで受ける | +| 2 つ目以降の区間へ `run` の引数を引き継がない | `--resume` や `-c` を引き継ぐと捨てた会話へ戻る。引き継いでよい引数を判定すると、claude の引数の意味を中継が持つことになる | +| 中継が要らない起動は、深さの変数だけを足して本物の claude を exec する | 擬似端末を挟むと出力の形・終了コード・シグナルの届き方が変わる。Claude Code から継いだ環境変数を外すと、直接打ったときと振る舞いが変わる | +| alias は版に依らない安定した場所を指し、SessionStart hook が起動ごとに置き直す | 版つきのキャッシュを指すと古い版に固定され、古い版のディレクトリが消えると alias が壊れる。動いている中継は入れ替えない(子の端末を手放すことになる) | +| alias はログインシェルの設定へ印のついた囲みで 1 度だけ足し、消されたら足し直さない。既存の `claude` の定義があれば足さない | 利用者が明示に自動を求めた。囲みの外は書き換えず、書く前にバックアップを取る。消したのは利用者の判断で、既存の定義を上書きすると選んだ起動の仕方が黙って替わる。bash と zsh 以外は書き方が違い、読み違えると設定を壊す | +| 文脈の上限は既存の文脈量の hook が作り、中継の下では 1 度の通しをやめる | 上限の値と読み方を 1 つにし、測る側と止める側を食い違わせない。人の居ない前提で LLM が「続ける」と決めると上限を超えたまま進む。Stop hook で上限を見て応答を続けさせると、文で尋ねた関門まで承認の前に切る | +| 背景の処理が動いている Stop では印を書かない。判定は `background_tasks` の `status: running` だけで行う | 動いているあいだに切ると、その処理(supervisor を含む)が子の claude と一緒に終わる。背景の Bash もサブエージェントも同じ形で載る。conductor が自分で数えると数え違えて子を失う | +| 次の区間の中身は `/goal` を含めたまま位置引数 1 つで渡す | `/goal ...` の複数行の位置引数でも、改行ごと 1 つの条件として目標が設定される | +| 文脈量の hook は `relay.py is-child` で中継の直接の子かを見る | bash の hook に親のたどりを写すと、2 つの実装が食い違う | +| 待ちの秒数と打ち切りはすべて環境変数で短くできる | 擬似端末の上の単体テストを数十秒で終える | + +## 仕様 + +### 常に成り立つ条件 + +- **`mark` と `install` は常に終了コード 0 で終わる。** 例外も含めて Stop と SessionStart を止めない。 + `mark` は何も出力しない +- **中継は本体の例外で子の claude を巻き込まない。** 中継が落ちると擬似端末が閉じ、子は SIGHUP で + 終わる。切れ目の判定の中の例外は `stop` の行(`error`)と `ndf-relay:` の 1 行に変え、子が終わる + まで入出力の中継だけを続ける +- **中継は端末の属性を始める前の状態へ戻して終わる。** 正常な終わり・例外・SIGTERM と SIGHUP の + 受け取りのすべての経路で `tcsetattr` で戻す。端末が閉じていて戻せないときは、その失敗を無視する +- **印を書くのは中継の直接の子の claude だけで、`AskUserQuestion` の答えを待つあいだと背景の処理が + 動いているあいだは書かない。** 直接の子でない claude の Stop は印を読みも消しもしない +- **中継は同じ起動の作業ディレクトリしか読まない。** 起動ごとに新しいディレクトリを作るので、pid が + 再利用されても前の起動の `stop` や `next.json` を読まない +- **中継が生きているかは `relay.lock` の排他で見る。** pid の生死では見ない +- **印と作業ディレクトリの形(`next.json` のキーと `NDF_RELAY_DIR` のファイル名)は版をまたいで + 変えない。** hook は区間ごとに新しい版で動き、動いている中継は古い版のままでありうる。変えるときは + ファイル名を変え、古い中継が新しい印を読み違えないようにする +- **子の起動は、印を消す → `pty.fork()` → 子は同期のパイプで待つ → 親が `child.pid` を書く → 親が + 同期のパイプを閉じる → 子が exec → 親が結果のパイプで成功を確かめて `start` を書く、の順に固定する。** + 子がすぐ Stop に達しても hook が読む `child.pid` は新しい値で、親が消す印は前の区間のものだけになる。 + `start` は exec に成功した区間にだけ残る + +### `relay.py` の副命令 + +| 副命令 | 引数 | 終了コード | 出力 | +| --- | --- | --- | --- | +| `run` | `[claude の引数 ...]`。中継は解釈しない | 素通しでは claude の終了コードそのもの(exec で置き換わる)。中継では最後の区間の claude の終了コード(シグナルで終わったら 128 + 番号)/ 2: 次の区間の更新か起動に失敗した / 127: 本物の claude が見つからない・起動の入れ子・1 つ目の区間の exec の失敗 | 区切りの 1 行と `ndf-relay:` の 1 行。印なしで終わるときは何も出さない | +| `stop` | 無し | 0: 動いている中継に停止の印を置いた(1 つ以上)/ 1: 動いている中継が無い | 置いた中継の pid を 1 行ずつ | +| `mark` | 標準入力に Stop hook の JSON | 常に 0 | 無し | +| `install` | 無し | 常に 0 | 足したときと、既存の定義で足さなかった初回だけ `{"systemMessage": "<1 行>"}` | +| `is-child` | 無し(内部用。`relay.md` に載せない) | 0: 中継が動いていて、呼んだ claude が中継の直接の子 / 1: それ以外 | 無し | + +副命令が無い・知らない副命令は、使い方を標準エラーへ出して終了コード 2 で終わる。 + +`stop` は `NDF_RELAY_DIR` を継がないため、`${XDG_STATE_HOME:-$HOME/.local/state}/ndf/relay/` の各 +サブディレクトリを走査し、`relay.lock` が取れない(中継が動いている)ものすべてに `stop` を置く。 + +### 素通しの条件 + +`run` は上から順に見る。素通しは下の「本物の claude」の絶対パスを `os.execve` に渡し、引数を +そのまま渡す。**環境は `NDF_RELAY_DEPTH` を 1 増やすことだけを変える。** + +| # | 条件 | すること | +| ---: | --- | --- | +| 1 | `NDF_RELAY=0` | 素通し | +| 2 | `NDF_RELAY_DEPTH` が 2 以上 | `ndf-relay: 起動が入れ子になっている(<本物の claude の候補>)` を出して終了コード 127。`claude` という名前のラッパーが中継を呼び返す繰り返しを止める | +| 3 | 本物の claude が見つからない | `ndf-relay: 本物の claude が見つからない` を出して終了コード 127 | +| 4 | `NDF_RELAY_DIR` がある(中継の子の中で打たれた) | 素通し。入れ子の中継にしない | +| 5 | 引数に `-p` / `--print`(`--print=` を含む)/ `-h` / `--help` / `-v` / `--version` がある、または最初の引数が claude の副命令 | 素通し | +| 6 | 標準入力か標準出力が端末でない | 素通し | +| 7 | `pty` / `termios` / `tty` を読み込めない、`claude plugin list --json` から `ndf@<名前>` の名前と版を読めない(15 秒で打ち切る)、作業ディレクトリを作れない | `ndf-relay: 中継を始めない(<理由>)…` を標準エラーへ出してから素通し | +| 8 | 上のどれでもない | 中継として始める | + +副命令の一覧は Claude Code 2.1.280 の `claude --help` から写した(`agents` / `attach` / `auth` / +`auto-mode` / `doctor` / `gateway` / `import` / `install` / `logs` / `mcp` / `plugin` / `plugins` / +`project` / `respawn` / `rm` / `setup-token` / `stop` / `kill` / `ultrareview` / `update` / `upgrade`)。 +一覧に無い副命令は対話として中継を挟むが、端末を使わずに終われば印は書かれず、中継は同じ終了 +コードで終わる。 + +### 本物の claude + +alias は子のプロセスには効かないため、中継は実体を探す。**素通しも区間の起動も、ここで決めた +絶対パスを使い、名前 `claude` で `PATH` を引き直さない。** + +1. 環境変数 `NDF_RELAY_CLAUDE` があればそれ +2. `PATH` を前から見て、実行できる `claude` のうち、実体(`realpath`)が `relay.py` 自身とその安定した + 置き場所でなく、先頭 4 KB に `relay.py` を含まないもの。読めないファイルは中継と見なさない(飛ばし + 損ねた繰り返しは `NDF_RELAY_DEPTH` が止める) + +### 中継の流れ + +```mermaid +stateDiagram-v2 + [*] --> 素通し: 非対話・副命令・中継の下・NDF_RELAY=0・始められない + 素通し --> [*]: 本物の claude を exec + [*] --> 中継する: 1 つ目の区間を起動した + 中継する --> 静まりを待つ: 印が現れた + 静まりを待つ --> 中継する: 印が消えた・記録か入力が動いた + 静まりを待つ --> 続けさせる: 停止の印・上限・空回り・例外 + 静まりを待つ --> 終わらせる: 静まった + 続けさせる --> [*]: 子が終わった(子の終了コード) + 終わらせる --> 起動する: 子が終わった + 起動する --> 中継する: 更新と起動に成功 + 起動する --> [*]: 更新か起動に失敗(終了コード 2) + 中継する --> [*]: 子が印なしで終わった(子の終了コード) +``` + +| 段 | すること | +| --- | --- | +| 中継する | 端末の属性を保存して標準入力を raw にし、`select` で標準入力 → マスタ、マスタ → 標準出力を流す(Ctrl-C もバイトのまま子へ届く)。SIGWINCH で端末の大きさをマスタへ `TIOCSWINSZ` で写す。`NDF_RELAY_POLL` 秒(既定 2)ごとに印を見る。1 つ目の区間は今の作業ディレクトリで `<本物の claude> ` を起動する | +| 静まりを待つ | 次がそろうまで待つ。(1) 印の `written_at`・`transcript_path` の更新時刻・利用者の最後の入力の時刻のうち最も遅いものから `NDF_RELAY_QUIET` 秒。(2) 目標のある区間では、印より後の判定の記録(下の「目標の判定の記録」)。(3) 印が消えていない | +| 続けさせる | 停止の印・1 日の起動回数・空回りのどれかに当たるか、判定の中で例外が起きたら、`/exit` を入力しない。`stop` の行を書き、印を消し、`ndf-relay:` の 1 行を出す。以後は入出力を中継するだけで、次の印では何もしない。子が終わると `end`(`no-mark`)を書いて子の終了コードで終わる | +| 終わらせる | 子の端末へ `/exit` を書き、`NDF_RELAY_EXIT_GAP` 秒(既定 1)おいて `\r` を書く。`NDF_RELAY_EXIT_WAIT` 秒(既定 30)で終わらなければ SIGTERM、`NDF_RELAY_TERM_WAIT` 秒(既定 10)でも終わらなければ SIGKILL を送り、終わりを `waitpid` で確かめてから `end` を書く | +| 起動する | `claude plugin marketplace update <名前>` → `claude plugin update ndf@<名前> -y` → `claude plugin list --json` で版を読む。どれかが 0 以外・打ち切り・版が読めなければ `update-failed`。作業ディレクトリは印の `cwd`、消えていればパスの `/.worktrees/` の手前(主ディレクトリ)、無ければ在る最も近い親、それも無ければ HOME。区切りの 1 行を出し、`<本物の claude> <印の中身>`(中身は 1 つの引数)を起動する。exec に失敗したら `start-failed` | + +**更新と起動に失敗したときは、`stop` の行を書き、次のコマンドの中身を画面に出して終了コード 2 で +終わる。** 1 つ目の区間の exec に失敗したときは、`stop`(`start-failed`)を書き `ndf-relay:` の 1 行を +出して終了コード 127 で終わる。素通しし直さない(同じ実体の exec がまた失敗する)。 + +**exec の成否は close-on-exec の結果のパイプで親へ返す。** 子は exec が失敗したら `errno` をパイプへ +書いて終わる。親は何も読まずに閉じれば成功とする。失敗した起動は `start` も `end` も書かず、1 日の +起動回数に数えない。 + +**子の環境からは `CLAUDECODE` / `CLAUDE_CODE_SESSION_ID` / `CLAUDE_CODE_ENTRYPOINT` を外し、 +`NDF_RELAY_DIR` を置き、`NDF_RELAY_DEPTH` を 1 増やす。** Claude Code の中から起こしたプロセスは +これらを継ぐためである。`claude plugin ...` の呼び出しは、同じ変数と `NDF_RELAY_DIR` を外し、 +標準入力を `/dev/null` にして行う。打ち切りは `list` が 15 秒、`marketplace update` と `plugin update` +が 120 秒である。応答しない CLI で端末が止まらないためである。 + +**マーケットプレイスの名前と 1 つ目の区間の版は、中継を始めるときの `claude plugin list --json` の +`id` が `ndf@<名前>` の要素から読む。** 開発版のチャネルを使う利用者でも、登録した取得元から更新される。 + +### 上限と止め方 + +| 上限・手段 | 判定 | +| --- | --- | +| 1 日の起動回数 | `${XDG_STATE_HOME:-$HOME/.local/state}/ndf/relay/` の全 `log.jsonl` の `start` のうち、`at` が端末の地方時で今日のものを数え、`NDF_RELAY_MAX_STARTS`(既定 20)以上なら `max-starts`。**数えてから `start` を書くまで(失敗なら放すまで)親の `count.lock` を排他で持つ。** 取れなければ次の確認まで待つ。同時に動く中継が残り 1 枠を取り合っても上限を超えない | +| 空回り | この中継の記録の直前 2 つの `end` の `seconds` がともに `NDF_RELAY_SPIN`(既定 120)未満で、今の区間の長さ(印の `written_at` − 区間の起動の時刻)も未満なら `spin`。1 つ目の区間(`run` の引数で起動した区間)も数える | +| 停止の印 | 作業ディレクトリに `stop` があれば `stop-file`。`relay.py stop` か `touch <作業ディレクトリ>/stop` で置く | +| 区間の中の `/exit`・Ctrl-C の 2 回・子が落ちた | 印が無いまま子が終わるので、中継も次の区間を起動せずに終わる | + +### `mark` の判定 + +| # | 条件 | すること | +| ---: | --- | --- | +| 1 | `NDF_RELAY_DIR` が無い | 何もしない(hook の定義の側でも `python3` を起こさない) | +| 2 | `relay.lock` の排他が取れる(中継が動いていない) | 何もしない | +| 3 | 標準入力を JSON として読めない | 何もしない | +| 4 | hook の親をたどって最初に当たる claude が `child.pid` と違う | 何もしない(印を消さない) | +| 5 | `last_assistant_message` の `ndf-next` のブロックがちょうど 1 つで、`background_tasks` に `status: running` が無い | 印を書く(前の印は置き換わる) | +| 6 | ブロックが 0 か 2 つ以上、または背景の処理が動いている | 印があれば消す。処理が終わって応答し直した Stop で改めて書かれる | + +**親のたどり** は Linux では `/proc//stat`、それ以外では `ps -o ppid=,comm=` で行う。名前が +`claude` か、子と同じ名前か、pid が `child.pid` のプロセスに当たった時点で決める(最大 64 段、pid 1 +で打ち切る)。conductor が Bash から起こした `claude -p` は子の claude の孫に当たり、先にそちらに当たる。 + +**ブロックの読み取りは外側の囲みの中を除く。** 応答を行ごとに読み、囲みの開き(行頭のバッククォート +3 つ以上)と閉じ(同じ数以上のバッククォートだけの行)を数える。数えるのは、どの囲みの中でもない +位置で開く、バッククォートがちょうど 3 つで情報文字列が `ndf-next` の囲みだけである。中身は前後の +改行を除いたもので、複数行でよい。 + +### 目標の判定の記録 + +`/goal` の目標の設定と判定は、会話の記録の `type: "attachment"` の行の `attachment.type: +"goal_status"` に書かれる。設定は `sentinel: true`、判定は `met`(未達で止めを拒めば `false`、達成 +なら `true`)を持つ。**最後の `sentinel` の行の後に `met: true` の判定が無く、印の `written_at` より +後の判定の行(`timestamp`)も無ければ、中継は `/exit` を入力せずに待つ。** 止めを拒まれて応答が +続き、ブロックが出なければ印は消え、中継は待ち続ける。`stop_hook_summary` の +`preventedContinuation` は未達でも `false` のままなので使わない。 + +### 文脈の上限で切る + +**上限は `NDF_CONTEXT_LIMIT`(既定 200,000)の 1 つで、中継は別の値を持たない。** + +| 誰が | 何をする | +| --- | --- | +| 文脈量の hook | conductor が工程へ入る起動(工程 Skill・持ち場の Agent)で上限を超えていれば止める。これが「前の持ち場の報告を受け取った後で、次の持ち場を起動する前」の切りの良いところに当たる | +| 文脈量の hook(中継の下) | `NDF_RELAY_DIR` があり、上限を超えていて、`relay.py is-child` が 0 なら、同じ起動の 1 度の通しをしない。理由の欄は「新しい持ち場を起動せず、動いている supervisor の報告を待ち、引継ぎ文書を更新し、`ndf-next` のブロックを出して終える」を示す。中継の外では今までどおり 1 度だけ通す | +| conductor | 背景の supervisor の報告をすべて受け取ってから、引継ぎ文書(`/goal` の指示が名指ししたもの。無ければ書かない)を更新し、ブロックを出して終える。中身は `/goal /ndf:development-workflow #<課題>`、名指しの引継ぎ文書があれば「<文書> の続きから」 | +| Stop hook と中継 | 背景の処理が残っていれば印を書かない。すべて終わった後の Stop で印が書かれ、中継がほかの切れ目と同じく次の区間を起動する | + +hook が止めるのは工程へ入る起動だけで、持ち場の中の Bash や Read は止めない。 + +### `install` + +**alias は安定した場所 `${XDG_DATA_HOME:-$HOME/.local/share}/ndf/relay.py` を指す。** SessionStart +hook は起動した版のパスで動くので、`claude plugin update` の後の最初の起動(中継が起動する次の区間を +含む)で写しが新しい版になる。動いている `run` は古い版のまま最後まで動き、利用者が次に `claude` と +打ったときから新しい版になる。 + +| # | 段 | すること | +| ---: | --- | --- | +| I1 | 止める | `NDF_RELAY_AUTO=0` なら何もしない。それ以外は状態の親とデータの親を権限 `0700` で作り、`install.lock` の排他を 2 秒まで待って取る(取れなければ何もせず終わる)。I2〜I7 はこのロックの中で行う | +| I2 | 置き直す | 自分と写しの中身が違うか写しが無いときだけ、一時ファイルに書いて権限 `0755` にしてから置き換える | +| I3 | 足す先 | `$SHELL` の名前が `bash` なら `~/.bashrc`、`zsh` なら `${ZDOTDIR:-$HOME}/.zshrc`。それ以外では足さない | +| I4 | 既にある | 足す先に `# >>> ndf relay >>>` の行があれば何もしない | +| I5 | 利用者が消した | 記録 `rc-added` に足す先のパスがあるのに囲みが無ければ、足さない。足し直すには記録の行を消す | +| I6 | 既存の定義 | 足す先(bash では `~/.bash_aliases` も)に、行頭の `alias claude=`、`function claude`、`claude()` / `claude ()` があれば足さない。記録 `rc-skipped` に無ければ案内を 1 度だけ出して記録する | +| I7 | 足す | 足す先があれば `<足す先>.ndf-bak-` へ写してから、末尾へ下の囲みを追記する(無ければ作る)。`rc-added` に記録し、足したことを知らせる | + +```bash +# >>> ndf relay >>> +# ndf の中継(区間の切れ目で claude を自動で起動し直す)。消せば元に戻る。 +alias claude='python3 "${XDG_DATA_HOME:-$HOME/.local/share}/ndf/relay.py" run' +# <<< ndf relay <<< +``` + +## データ・設定 + +### 作業ディレクトリ `NDF_RELAY_DIR` + +`${XDG_STATE_HOME:-$HOME/.local/state}/ndf/relay/-<中継の pid>-<乱数 8 桁>/`。 +`run` が親を `0700` で作ってから、その下を `0700` の `os.mkdir` で新しく作る(`install` が一度も +走っていなくても始められる。既にあれば別の乱数で作り直す)。`run` が終わるとき `relay.pid` を消し、 +`log.jsonl` は残す。 + +| ファイル | 書く側 | 中身 | +| --- | --- | --- | +| `relay.lock` | `run` | 動いている間 `fcntl.flock` の排他を持ち続ける。`mark` / `stop` / `is-child` は `LOCK_EX \| LOCK_NB` を試し、取れなければ動いていると読む。中継が落ちると OS が放す | +| `relay.pid` | `run` | 中継の pid(表示のため。生死の判定には使わない) | +| `child.pid` | `run` | 今の区間の claude の pid。区間ごとに書き換える | +| `next.json` | `mark` | 印。権限 `0600` の一時ファイルに書いてから置き換える | +| `stop` | `stop` か利用者 | 空。在れば停止の印 | +| `log.jsonl` | `run` | 記録 | + +状態の親には、作業ディレクトリのほかに `count.lock`・`install.lock`・`rc-added`・`rc-skipped` を置く。 + +### 印(`next.json`) + +| キー | 値 | 出所 | +| --- | --- | --- | +| `command` | ブロックの中身 | `last_assistant_message` | +| `cwd` | 作業ディレクトリ | Stop hook の `cwd` | +| `session_id` / `transcript_path` | 会話の ID と記録の場所 | Stop hook の入力 | +| `written_at` | UTC の ISO 8601(ミリ秒まで) | `mark` の時刻 | + +### 記録(`log.jsonl` の 1 行) + +| キー | 値 | +| --- | --- | +| `event` | `start`(区間を起動した)/ `end`(区間の claude が終わった)/ `stop`(次の区間を起動しないと決めた) | +| `at` | UTC の ISO 8601。`start` は同期のパイプを閉じた時刻 | +| `section` | 区間の番号(中継の中で 1 から) | +| `pid` | 区間の claude の pid(`start`・`end`) | +| `command` | 起動に渡した中身(`start`)。1 つ目の区間は `run` の引数をシェルの形でつないだもの(引数なしなら空) | +| `from_session` | 前の区間の `session_id`(`start`。1 つ目は空) | +| `plugin_version` | 起動の直前に読んだ `ndf@<名前>` の版(`start`) | +| `cwd` / `cwd_fallback` | 起動した作業ディレクトリと、印の `cwd` が消えていたときの元の値(`start`。消えていなければ `cwd_fallback` は無い) | +| `seconds` | 区間の長さ(`end`)。起動から、印の `written_at`(`mark` / `sigterm` / `sigkill`)か子の終わり(`no-mark`)まで | +| `ended_by` | `mark`(`/exit` で終わった)/ `no-mark`(印なしで終わった)/ `sigterm` / `sigkill`(`end`) | +| `reason` | `stop-file` / `max-starts` / `spin` / `update-failed` / `start-failed` / `error`(`stop`)。`start-failed` は `errno` も持つ | + +**`end` は区間の claude が実際に終わったときだけ書く。** 落ちると決めたときは `stop` の行だけを書き、 +その区間が後で終わったときに `end`(`no-mark`)を書く。中継そのものが SIGTERM / SIGHUP で先に終わると、 +その区間の `end` は書かれない(子は擬似端末が閉じて後から終わる)。 + +### 環境変数 + +| 変数 | 既定 | 意味 | +| --- | --- | --- | +| `NDF_RELAY` | — | `0` で `run` を常に素通しにする | +| `NDF_RELAY_AUTO` | — | `0` で `install` が何もしない | +| `NDF_RELAY_MAX_STARTS` | `20` | 1 日の起動回数の上限(全部の中継の合計) | +| `NDF_RELAY_QUIET` | `15` | 静まりの秒数 | +| `NDF_RELAY_SPIN` | `120` | 空回りとみなす区間の秒数 | +| `NDF_RELAY_POLL` | `2` | 印を見る間隔(秒) | +| `NDF_RELAY_EXIT_GAP` / `NDF_RELAY_EXIT_WAIT` / `NDF_RELAY_TERM_WAIT` | `1` / `30` / `10` | `/exit` と改行の間・`/exit` の後の待ち・SIGTERM の後の待ち(秒) | +| `NDF_RELAY_LIST_TIMEOUT` / `NDF_RELAY_UPDATE_TIMEOUT` | `15` / `120` | `plugin list` と `marketplace update`・`plugin update` の打ち切り(秒) | +| `NDF_RELAY_CLAUDE` | — | 本物の claude の絶対パス(最優先) | +| `NDF_RELAY_DIR` | — | 中継が子に置く作業ディレクトリ。hook と文脈量の hook が中継の下かをこれで見る | +| `NDF_RELAY_DEPTH` | `0` | 起動の深さ。素通しと子の起動で 1 増やし、2 以上の `run` は止まる | + +## 外部連携 + +| 相手 | 使うもの | +| --- | --- | +| Claude Code の Stop hook の入力 | `last_assistant_message`・`background_tasks`(`status`)・`cwd`・`session_id`・`transcript_path`。`stop_hook_active` は見ない | +| Claude Code の CLI | `claude plugin list --json`(`id` が `<プラグイン>@<マーケットプレイス>`、`version`)・`claude plugin marketplace update <名前>`・`claude plugin update ndf@<名前> -y`(端末でなければ `-y` が要る)。区間の起動は位置引数の最初の入力(スラッシュコマンドと `/goal` も入力として働く) | +| 会話の記録 | `transcript_path` の更新時刻と `goal_status` の attachment の行 | + +**前提にしている Claude Code の振る舞い**(2.1.280・Linux で実測): Stop hook は応答が終わるたびに +発火し、`AskUserQuestion` の答えを待つあいだと claude の終了では発火しない。`/goal` の判定は Stop hook +と並んで発火し、未達なら応答が続く。擬似端末のマスタへ書いた `/exit` と `\r` で約 1.6 秒で終わり、記録は +壊れない。入力待ちの子の端末は raw で、`\x03` はバイトのまま Ctrl-C として届く。背景の Bash と +サブエージェントは `background_tasks` に `running` で載り、終わると空の配列に戻る。 + +## セキュリティ + +- `NDF_RELAY_DIR` とその親は `0700`、印は `0600` で、同じ利用者のプロセスだけが読み書きできる +- 次のコマンドはシェルも `PATH` の探索も通さず、本物の claude の絶対パスと引数の配列で `os.execve` に + 渡す。`claude` という名前のラッパーへ戻らない +- `install` が書くのはログインシェルの設定の印のついた囲みの中だけで、囲みの外は読むだけである。書く前に + バックアップを取る + +## 運用 + +- **始める:** 利用者の手作業は無い(SessionStart が `install` を呼ぶ)。次に開いたシェルから効く +- **止める:** 1 回だけなら `NDF_RELAY=0 claude`。切り替えだけを止めるなら `relay.py stop` か停止の印。 + alias を戻すなら囲みを消す(`NDF_RELAY_AUTO=0` で `install` も止まる)。データの移行は無い +- **区間をまたいで設定を保つ:** 2 つ目以降の区間は印の中身だけで起動するので、モデルなどは設定か + 環境変数(`ANTHROPIC_MODEL` など)で与える +- **前の区間の画面:** claude は区間ごとに代替画面を使うため、端末の履歴に残るのは + `Resume this session with: claude --resume ` と区切りの 1 行だけである。会話の記録は残る +- **既知の制約:** `install` が既存の `claude` の定義を探すのは足す先と `~/.bash_aliases` だけで、別の + ファイルから読み込む定義は見落とし、囲みの alias が後から上書きする。利用者は囲みを消せば戻せる +- **費用:** 中継の待ちは `select` とファイルの確認で、LLM を使わない。`mark` は `NDF_RELAY_DIR` が無ければ + `python3` を起こさない。`install` は 2 回目以降、ファイル 1 つの読み比べで終わる + +## テスト観点 + +単体テストは `plugins/ndf/scripts/tests/test_relay.py`(子の claude を擬似端末の上で動く +`tests/fixtures/relay_fake_claude.py` に差し替え、`claude plugin ...` も同じ差し替えが受ける。`install` +と `run` は一時の HOME と `XDG_*` の下でだけ動かす)と、`test_token_guard.py` の中継の下の 4 件にある。 + +- `mark`: ブロック 1 つで印が 5 つのキーで書かれ、複数行を保ち、権限が `0600` であること。`stop_hook_active` + が真でも書くこと。`NDF_RELAY_DIR` 無し・中継が動いていない・ブロック 0・2 つ・4 つのバッククォートの囲みの + 中だけ・壊れた JSON・直接の子でない・情報文字列 `text` で、印が無く出力が空で終了コード 0 であること。 + ブロック無しの Stop で前の印が消え、直接の子でない claude の Stop では消えないこと。`background_tasks` に + `running` があれば書かずに前の印を消すこと +- 素通し: `NDF_RELAY=0`・中継の下・`-p`・`--help`・副命令・標準入力がパイプで、本物の claude のパスと元の + 引数が渡り、環境の差が `NDF_RELAY_DEPTH` だけで、何も出力しないこと。`pty` が読めない・`plugin list` の + 失敗・`ndf@` が無い・`plugin list` の打ち切りで、1 行を出してから素通しすること。`claude` という名前の + ラッパーを飛ばし、読めないファイルは中継と見なさず、`NDF_RELAY_CLAUDE` が最優先で、深さ 2 と本物の + 不在で終了コード 127 になること +- 中継: 1 つ目の区間が `run` の引数を受け、印なしで終われば子の終了コード(シグナル 15 なら 143)で何も + 出さずに終わること。切れ目で `/exit` と `\r` → 子の終わり → `marketplace update` → `plugin update -y` → + `plugin list --json` → 区切りの 1 行 → 次の子の起動(中身が 1 つの引数)の順になり、3 つ目の区間まで + 続くこと。記録の `start` と `end` のキーと `plugin_version` の値が合うこと。利用者の入力・記録の更新・ + 目標の判定待ちのあいだは `/exit` を送らず、印が消えれば取りやめること +- 端末: キー入力(`\x03` を含む)と大きさの変化が子へ届くこと。子の終わり・例外・SIGTERM・SIGHUP の後に + 端末の属性が戻り、例外でも子を巻き込まないこと +- 落とし先と上限: 消えた作業ツリーから主ディレクトリで起動し `cwd_fallback` が載ること。今日の `start` が + 上限の記録で `max-starts`、残り 1 枠を 2 つの中継が取り合って起動が 1 つだけになること。119・119・119 で + `spin`、119・121・119 では送ること。`/exit` にも SIGTERM にも反応しない子で `sigkill` を書くこと。更新の + 失敗・打ち切り・次の子の exec の失敗で `stop` と次のコマンドを出して終了コード 2、1 つ目の子の exec の + 失敗で終了コード 127 になること +- `stop`: 動いている中継すべてにだけ停止の印を置き、`relay.pid` が無関係な生きたプロセスを指すディレクトリを + 飛ばし、動いている中継が無ければ終了コード 1 であること。同じ pid で 2 回作った作業ディレクトリが別で(`0700`・空)、 + 前の起動の `stop` と `next.json` を持ち込まず、親が無くても作ること +- `install`: 1 回目に写し・囲み・バックアップ・`rc-added`・`systemMessage` ができ、2 回目は何も変えず、 + 囲みを消した後は足さないこと。既存の `alias` / 関数(`~/.bash_aliases` を含む)では足さず案内が 1 回だけで + あること。`ZDOTDIR`・bash と zsh 以外のシェル・`NDF_RELAY_AUTO=0`・写しの置き換え・書けない場所・4 つの + 同時起動・ロックの保持を扱えること +- 文脈量の hook: 中継の直接の子の conductor では、上限を超えた持ち場の起動が 2 回続けて止まり、理由が + `ndf-next` と supervisor の報告の待ちを含むこと。直接の子でない・中継が動いていないときは 1 度だけ通すこと +- Codex / agy の hook の定義に `mark` と `install` が無いこと(`claude plugin validate .` を含む) + +**本物の Claude Code での通しの確かめ**(2.1.280・`--model haiku`・擬似端末の上。プラグインの更新は +差し替えのラッパーが受けた。記録は [PR #921](https://github.com/devbasex/ai-plugins/pull/921) の本文): +人の入力が最初の入力と `AskUserQuestion` の答えだけで 3 つ目の区間まで起動し、`start` 3 行と `end` +(`mark`)2 行が残ったこと。質問の表示中に印が無く、答えた後に印が現れて切り替わったこと。 +`NDF_CONTEXT_LIMIT=30000` で持ち場の起動が 2 回とも止まり、ブロックが出て次の区間が起動したこと。背景の +サブエージェントが動いているあいだは印が書かれなかったこと。 + +**実機で確かめていないこと:** + +- macOS での親のたどり(`ps` の経路) +- 本物の `claude plugin update` を挟んだ切り替え(古い版のディレクトリが消える場合を含む)。通しの確かめでは + 差し替えのラッパーが更新を受けた +- 利用者の本物の端末(VS Code の統合ターミナル)での見た目と、大きさの変化で本物の claude が描き直すか + (単体テストは子の端末の大きさが変わるところまでを見る) + +## 関連リンク + +- [#895](https://github.com/devbasex/ai-plugins/issues/895)(設計は [PR #908](https://github.com/devbasex/ai-plugins/pull/908)、実装は [PR #921](https://github.com/devbasex/ai-plugins/pull/921)) +- [#827](https://github.com/devbasex/ai-plugins/issues/827) — supervisor の層のスクリプト駆動。「何が claude を起動し、状態をどこに持つか」の答え(スクリプトが起動し、正本は会話の外の記録、LLM の結果は hook がファイルへ写す)を共有する +- [ndf-token-waits-and-context-cut.md](ndf-token-waits-and-context-cut.md) — 文脈量の hook と引き継ぎの 1 行 +- [ndf-agent-layers-unattended-run.md](ndf-agent-layers-unattended-run.md) — 3 層の運転 +- [ndf-context-window-metrics.md](ndf-context-window-metrics.md) — 会話の記録から文脈量を測る部品 diff --git a/docs/specifications/ndf-token-waits-and-context-cut.md b/docs/specifications/ndf-token-waits-and-context-cut.md index 1d5cfe50a..6ca330bbf 100644 --- a/docs/specifications/ndf-token-waits-and-context-cut.md +++ b/docs/specifications/ndf-token-waits-and-context-cut.md @@ -48,7 +48,7 @@ Claude Code の PreToolUse hook(`plugins/ndf/scripts/token-guard.sh`)が、 | 途中の通知 | 背景の処理を残したまま応答を終えたサブエージェントについて、親へ届く 1 回目の通知。注記に「background work of its own still running」「may be interim」と出る | | 報告の写し | worker が起動指示の `置き場所` のファイルの末尾へ書く `## 作業の報告` の節。最後の応答の報告と同じ中身 | | 完了の目印 | worker が報告の写しを書き終えた後に作る空のファイル `<置き場所>.done` | -| 引き継ぎの 1 行 | 新しい会話の最初に打てば、その工程から再開できるコマンド 1 行。`/ndf:development-workflow #<課題> [#<課題> ...]` | +| 引き継ぎの 1 行 | 新しい会話の最初に打てば、その工程から再開できるコマンド 1 行。`/ndf:development-workflow #<課題> [#<課題> ...]`。情報文字列 `ndf-next` の囲みのコードブロック 1 つで出す | ## 構成要素 @@ -173,6 +173,10 @@ graph TB 利用者は同じ起動をもう一度行えば続けられる。毎回拒否すると同じ工程をやり直せず、会話ごとに 1 度にすると以後の切れ目で止まらない。案内だけを足す形(`additionalContext`)は、規定が読み 流された実測があるため採らない +- **中継の直接の子の conductor では 1 度の通しをしない。** `NDF_RELAY_DIR` があり、上限を超えていて、 + `relay.py is-child` が終了コード 0 なら、上限を超えている限り同じ起動も止め続ける。人の居ない + 前提で LLM が「続ける」と決めると、上限を超えたまま進むためである。中継の外では 1 度だけ通す + ([ndf-relay-segment-restart.md](ndf-relay-segment-restart.md) の「文脈の上限で切る」) - **文脈量を読めない(記録が無い・`usage` が無い)ときは通す** **上限の既定は 200,000 で、`context-window.md` の「遅くとも 20 万」と `skill-stats.py` の @@ -215,7 +219,10 @@ graph TB Kiro では、それぞれの README が示す Skill の起動の書き方に読み替える。 **conductor は `context-window.md` の 4 つの切れ目と、文脈量の hook が拒否したときにこの 1 行を -出す。** 3 層では supervisor の持ち場の境がこの切れ目に当たるため、conductor が `## 持ち場の報告` +出す。** 出す形は情報文字列 `ndf-next` の囲みのコードブロック 1 つで、3 層(`/goal`)では中身の先頭を +`/goal ` にする(形の定義は `context-window.md` の「新しい会話で戻す」だけに置く)。中継の下では +中継がこのブロックを拾って次の会話を自動で起動し、中継が無ければ人が中身を貼り付ける +([ndf-relay-segment-restart.md](ndf-relay-segment-restart.md))。 3 層では supervisor の持ち場の境がこの切れ目に当たるため、conductor が `## 持ち場の報告` を受け取った時点で出し、supervisor は出さない。報告が `結果: 関門` のときは、関門の承認と 取り込み(設計 Pull Request のマージなど)が済んだ後に出す。関門の前に会話を切らないためである。 @@ -348,7 +355,7 @@ worker の 2 回目の通知は、写しを読んだ後に届いても読み直 | --- | --- | | sleep | 同じ条件の until ループを `run_in_background: true` で起動して完了通知を待つこと。出来事を 1 つずつ受けるなら `Monitor`。規約 `waiting.md`。`NDF_SLEEP_GUARD=0` | | 連続 Read | 書き終わりを待つなら until ループを `run_in_background: true` で起動するか、背景の処理の完了通知を待つこと。`tasks/*.output` は読まない。規約 `waiting.md`。`NDF_READ_REPEAT_GUARD=0` | -| 文脈量 | 文脈量と上限、利用者へ示す引き継ぎの 1 行(3 層なら新しい会話の `/goal` に渡す)、続けるなら同じ起動をもう一度行うこと。規約 `context-window.md`。`NDF_CONTEXT_GUARD=0` と `NDF_CONTEXT_LIMIT` | +| 文脈量 | 文脈量と上限、次のコマンドを `ndf-next` のブロック 1 つで示すこと(中身は引き継ぎの 1 行、3 層なら先頭に `/goal `)、続けるなら同じ起動をもう一度行うこと。中継の直接の子では、止め続けること・動いている supervisor の報告を待ってから引継ぎ文書を更新してブロックを出すこと。規約 `context-window.md`。`NDF_CONTEXT_GUARD=0` と `NDF_CONTEXT_LIMIT` | ### 4 ランタイム @@ -362,7 +369,8 @@ agy の CLI 側の消費を測った後に、登録するかを改めて決め - **止める:** 環境変数で判定ごとに止める。hook そのものを外すなら `hooks/claude.json` の登録を 1 つ外す。データの移行は無い -- **続ける:** 文脈量の拒否の後、利用者がこのまま続けると決めたら、同じ起動をもう一度行う +- **続ける:** 文脈量の拒否の後、利用者がこのまま続けると決めたら、同じ起動をもう一度行う。 + 中継の下ではこの手は無く、会話を切る - **性能:** 1 回の実行は、50 MB の記録でも競合しないとき 1 秒以内、ロックを待つときは 2 秒以内に 終わる。記録は末尾 200 行だけを読み、Bash と Read の判定は記録を読まない。登録の `timeout` は 5 秒で、`continueOnError: true` を付ける @@ -384,6 +392,9 @@ agy の CLI 側の消費を測った後に、登録するかを改めて決め - サブエージェントの中の起動・工程でない Skill・作業の種類の Agent を通すこと - 拒否の後の同じ起動を 1 度だけ通し(間に Bash と Read が挟まっても)、別の起動を再び拒否すること。 同じ起動の 2 回目を並列に起動しても通るのは 1 本だけであること +- 中継の直接の子の conductor では同じ起動が 2 回続けて止まり、理由の欄が `ndf-next` と supervisor の + 報告の待ちを含むこと。直接の子でない・中継が動いていないときは 1 度だけ通ること。中継の外でも理由の + 欄が `ndf-next` のブロックを求めること - 壊れた入力・`jq` の無い `PATH`・書けない控えの場所・取れないロック・読めない記録で、出力なし・ 終了コード 0 で通すこと。`guards/` の親が `wf_state_dir` の親と一致すること - 環境変数で判定ごとに止まり、上限が変わること @@ -408,6 +419,7 @@ agy の CLI 側の消費を測った後に、登録するかを改めて決め - [#829](https://github.com/devbasex/ai-plugins/issues/829) / [#830](https://github.com/devbasex/ai-plugins/issues/830)(親は [#827](https://github.com/devbasex/ai-plugins/issues/827)) - [#901](https://github.com/devbasex/ai-plugins/issues/901) — supervisor が worker の途中の通知で止まる(実装は [PR #910](https://github.com/devbasex/ai-plugins/pull/910)) +- [#895](https://github.com/devbasex/ai-plugins/issues/895) — 引き継ぎの 1 行を `ndf-next` のブロックにし、中継の下では文脈量の拒否を止め続ける([ndf-relay-segment-restart.md](ndf-relay-segment-restart.md)) - [#731](https://github.com/devbasex/ai-plugins/issues/731) — 待ちの道具(`bg-wait.sh`)を共通層へ移す - [ndf-context-window-metrics.md](ndf-context-window-metrics.md) — 会話の記録から文脈量を測る部品 - [ndf-agent-layers-unattended-run.md](ndf-agent-layers-unattended-run.md) — 3 層の運転 diff --git a/issues/old/README.md b/issues/old/README.md index c26756d9c..f850695a5 100644 --- a/issues/old/README.md +++ b/issues/old/README.md @@ -47,6 +47,7 @@ | [#829](https://github.com/devbasex/ai-plugins/issues/829) / [#830](https://github.com/devbasex/ai-plugins/issues/830) | 待つ間の問い合わせを止め、conductor の会話を工程の切れ目で切る(マイルストーン 26「17 トークン消費の削減」のまとまり 1)。確定仕様は [ndf-token-waits-and-context-cut.md](../../docs/specifications/ndf-token-waits-and-context-cut.md) | [milestone-26-token-waits/](milestone-26-token-waits/issue-829-830-requirements.md)(要求・設計・決定・計画の 4 本) | | [#828](https://github.com/devbasex/ai-plugins/issues/828) / [#680](https://github.com/devbasex/ai-plugins/issues/680) | サブエージェントに Skill 本文を丸ごと読ませず、仕事を分ける器を比べて選ぶ(マイルストーン 26「17 トークン消費の削減」)。確定仕様は [ndf-worker-agent-and-skill-excerpts.md](../../docs/specifications/ndf-worker-agent-and-skill-excerpts.md) | [milestone-26-worker-agent/](milestone-26-worker-agent/issue-828-680-requirements.md)(要求・設計・決定・計画の 4 本) | | [#892](https://github.com/devbasex/ai-plugins/issues/892) / [#901](https://github.com/devbasex/ai-plugins/issues/901) | cross-review の母集合にホストを入れ、supervisor が worker の途中の通知で止まらないようにする(マイルストーン 26「17 トークン消費の削減」)。確定仕様は [cross-review-participants-and-seats.md](../../docs/specifications/cross-review-participants-and-seats.md) / [ndf-token-waits-and-context-cut.md](../../docs/specifications/ndf-token-waits-and-context-cut.md) | [milestone-26-review-pool-and-interim-wait/](milestone-26-review-pool-and-interim-wait/issue-892-901-requirements.md)(要求・設計・決定・計画の 4 本) | +| [#895](https://github.com/devbasex/ai-plugins/issues/895) | 区間の切れ目の再起動と次のコマンドの入力を前景の中継で自動にする(マイルストーン 26「17 トークン消費の削減」)。確定仕様は [ndf-relay-segment-restart.md](../../docs/specifications/ndf-relay-segment-restart.md) / [ndf-token-waits-and-context-cut.md](../../docs/specifications/ndf-token-waits-and-context-cut.md) | [milestone-26-relay-restart/](milestone-26-relay-restart/issue-895-requirements.md)(要求・設計・決定・計画の 4 本) | ## 計画と調査資料 diff --git a/issues/issue-895-design-decisions.md b/issues/old/milestone-26-relay-restart/issue-895-design-decisions.md similarity index 100% rename from issues/issue-895-design-decisions.md rename to issues/old/milestone-26-relay-restart/issue-895-design-decisions.md diff --git a/issues/issue-895-design.md b/issues/old/milestone-26-relay-restart/issue-895-design.md similarity index 100% rename from issues/issue-895-design.md rename to issues/old/milestone-26-relay-restart/issue-895-design.md diff --git a/issues/issue-895-plan.md b/issues/old/milestone-26-relay-restart/issue-895-plan.md similarity index 100% rename from issues/issue-895-plan.md rename to issues/old/milestone-26-relay-restart/issue-895-plan.md diff --git a/issues/issue-895-requirements.md b/issues/old/milestone-26-relay-restart/issue-895-requirements.md similarity index 100% rename from issues/issue-895-requirements.md rename to issues/old/milestone-26-relay-restart/issue-895-requirements.md diff --git a/plugins/ndf/skills/development-workflow/references/relay.md b/plugins/ndf/skills/development-workflow/references/relay.md index cd007aff7..f60e53419 100644 --- a/plugins/ndf/skills/development-workflow/references/relay.md +++ b/plugins/ndf/skills/development-workflow/references/relay.md @@ -107,4 +107,4 @@ conductor では、文脈量の hook が工程へ入る起動を 1 度の通し cat ~/.local/state/ndf/relay/*/log.jsonl | jq -c 'select(.event == "stop")' ``` -設計と決定の理由は ai-plugins の課題 #895 の設計文書にある。 +入出力の契約と決定の理由は、ai-plugins の確定仕様 `docs/specifications/ndf-relay-segment-restart.md` にある。