doc-desk Mod: Claude が質問票 JSON を open_form ツールに渡すと、ブラウザに一枚のフォームを出して人に答えてもらい、回答を【doc-desk 回答】の固定形にして Claude に届ける (waitSeconds 以内ならツールの結果として、過ぎれば user turn…

使い方 (読み込み方、フォームの使い方、困ったとき) は 利用者ガイド にあります。
doc-desk Mod (MVP)。Claude が文書を書く前に、文書の構成案と決定してほしい論点を 質問票 JSON としてツール open_form に渡すと、この Mod が自己完結の HTML シートを ./doc-desk/ に書き、Python 3 の受信サーバをローカルに立ててブラウザで開きます。 人がブラウザで答えて [送信] を押すと、受信サーバが回答 JSON をファイルに書いて終了し、 Mod がそれを回答固定形 (【doc-desk 回答】 で始まる文) にして Claude に届けます。
届け方は 2 経路です。open_form はツール呼び出しの中で waitSeconds (既定 300 秒) まで回答を待ち、 届けば固定形を Tool result (status: "answered", reply) で返します (同期経路)。 上限に達した、人が Esc で中断した、受信サーバに届かなくなった、waitSeconds: 0 のときは status: "pending" を返してターンを終えてもらい、以後は clock.every の監視が回答を検知して $.prompt.submit で user turn として届けます (非同期経路)。
指摘モード: Claude が書き上げた文書を HTML にして doc-desk/<label>.doc.html に書き出し、 ツール open_review を呼ぶと、この Mod がそのファイルを検査して指摘の画面を出します。 人は段落や文字列にチップ (短くする、根拠が要る、ここは良い など 9 種) とコメントで指摘を付け、段落をその場で書き換え、消し、足し、動かせます (添削)。 受信サーバ、同期待ち、監視、ペインは open_form と同じで、指摘は同じ 【doc-desk 回答】 の固定形の ## 指摘 と ## 書き換え の節で届きます。
ライブ表示: Claude が文書を書き始める前にツール open_live を呼ぶと、source への Write の文を turn.step の input チャンクから逐次取り出してブラウザへ流します (SSE)。人は読みながら「ライブ指摘」を付けられ、 [書き終わったら直す] は次の Write か Edit の Tool result の context で、[今すぐ止めて直す] は $.turn.abort と $.prompt.submit で Claude に届きます。書き終えて同じ documentId で open_review を呼ぶと、同じタブが指摘の画面へ移ります。 設計は docs/doc-desk/live-view-design.md にあります。
背景と決定の記録は docs/doc-desk/plan.md にあります。 対象は Claude Code 2.1.278 の Claude Mods (function hooks、早期アクセス) です。
sequenceDiagram
participant U as 人
participant C as Claude
participant M as Mod
participant R as 受信サーバ (Python 3)
participant B as ブラウザ
C->>M: tool.call open_form(質問票 JSON)
M->>M: 検証 → ./doc-desk/<label>.json と .html を書く
M->>R: $.process.run([python, receiver.py, start, ...]) (シェルは使わない)
R-->>M: stdout に {"port": n, "pid": n} を 1 行 (3 秒で listen できなければ空)
M->>B: $.process.run([python, receiver.py, open, <url>])
M->>M: $.ui.open(pane) で URL と状態を表示
Note over M: $.clock.every(500ms) で回答ファイルを監視 (同期待ち中は経過秒数の更新だけ)
loop 同期待ち (waitSeconds まで、既定 300 秒)
M->>R: $.http.fetch(GET /wait?t=<token>&timeout=4)
R-->>M: 回答の POST か 4 秒で {"answered": true|false}
end
U->>B: 答えて [送信]
B->>R: POST /answer?t=<token> (JSON)
R->>R: <label>.answer.json を書き、保留中の /wait に応答して自動終了
alt waitSeconds 以内 (同期経路)
M->>M: 回答を読み (documentId と revision を照合)、固定形を作り、<label>.md を書き、ペインを閉じる
M-->>C: { result: {status:"answered", reply, files}, context }
else 上限到達・Esc で中断・受信サーバ喪失・waitSeconds が 0 (非同期経路)
M-->>C: { result: {status:"pending", url, files, wait}, context }
M->>M: 監視タイマーが回答を読み、固定形を作り、<label>.md を書く
M->>C: $.prompt.submit({ text: 回答固定形 })
end
同期経路があるのは、人が数分で答えるふつうの場合に、Claude のターンを切らずに回答を渡すためです。 pending だけだと Claude のターンが一度終わり、回答は別の user turn として届きます。
ライブ表示の流れ:
sequenceDiagram
participant U as 人
participant C as Claude
participant M as Mod
participant L as ライブ用の受信サーバ
participant B as ブラウザ
C->>M: tool.call open_live(live)
M->>L: receiver.py start --live (--out は無い)
M-->>C: { status: "opened", url }
B->>L: GET /events (SSE)
loop Claude が source に Write する
C->>M: turn.step の input チャンク (Write の引数 JSON の断片)
M->>L: POST /document { kind: append, seq } (待たずに投げる)
L-->>B: append
L-->>M: 応答に { comments, stop }
end
U->>B: 文字列を選んで指摘 → [書き終わったら直す] / [今すぐ止めて直す]
B->>L: POST /comments
alt 書き終わったら直す
M-->>C: Write の Tool result に context (【doc-desk ライブ指摘】)
else 今すぐ止めて直す
M->>M: $.turn.abort({ turnId })
M->>C: $.prompt.submit (【doc-desk ライブ指摘】)
end
Note over M,L: 60 秒ごとに GET /wait (keepalive)。Mod からの接触が 10 分途絶えると受信サーバは自分で終わる。<br/>keepalive が 3 回続けて届かなければ、Mod は受信サーバが死んだとみなしてライブ表示を手放す
C->>M: tool.call open_review (同じ documentId)
M->>L: POST /finish {} (未届の指摘を受け取り、候補に埋める。以後の /comments は 409)
M->>M: 指摘の画面の受信サーバを起動
M->>L: POST /finish { url }
L-->>B: redirect (タブが指摘の画面へ移る)。1 秒後に終了
| パス | 役割 |
|---|---|
hooks/register.ts | フックの登録と状態機械 (idle → waiting → submitting → idle) |
hooks/host/index.ts | session.start で $ を束ねた関数群の型 |
hooks/guard/paths.ts | 回答待ちの書き込みを止めるパスの正規化 (\ と /、..、Windows の大文字小文字) と照合 |
hooks/compact/record.ts | 圧縮の結果に差し戻す 【doc-desk 決定の記録】 の組み立て (文字数の上限と切り詰め) |
hooks/store/pending-record.ts | $.store に残す待機の記録の型、読み取り、古さの判定 (7 日) |
hooks/names.ts | plugin 名、ツール名、コマンド名、ペイン id、見出し語 |
hooks/form/form-v1.ts | 質問票 JSON スキーマ v1 と回答 JSON の型 |
hooks/form/validate.ts | 質問票の検証 (エラーを全部返す) |
hooks/form/outline.ts | 構成案の HTML で使える要素と属性 (検証と画面で共有)、構成案の検査 (使えない要素、印の過不足) |
hooks/form/answer.ts | 回答 JSON の読み取り (documentId と revision が質問票と違えば無視) |
hooks/form/schema.ts | $.tool.register に渡す JSON Schema (open_form、open_review、open_live) |
hooks/live/json-stream.ts | ライブ表示: Write の引数 JSON の断片から file_path と content を逐次取り出す状態機械 (純粋関数) |
hooks/live/live-v1.ts | ライブ表示: live の入力と LiveComment の型と検証、受信サーバの応答の読み取り |
hooks/live/comments.ts | ライブ表示: 指摘を届ける context と、止めた後に投入する prompt の文、証跡 .comments.json の 1 件 |
hooks/live/controller.ts | ライブ表示の状態と振る舞い (open_live、turn.step の観察、Write と Edit の後の処理、止めて届ける処理、open_review への引き渡し、keepalive) を閉包にまとめたもの。register.ts はフックから呼ぶだけ |
hooks/sheet/render-live.ts | ライブ表示の自己完結 HTML。SSE で受けた文書を小さな Markdown 描画器で描き、最下部に追従し、右にライブ指摘の欄と送った指摘の一覧 |
hooks/sheet/render-html.ts | 質問票 → 自己完結 HTML (素の JS を文字列で埋める)。左に構成案、右に選んだものの詳細 (全体の進み具合と次に見る項目 / 決定 / 表の説明)、下に進捗と送信。構成案の HTML はブラウザで DOMParser にかけ、許可した要素と属性だけで組み直す |
hooks/sheet/common.ts | 2 つの画面が共有する CSS と、HTML と JSON の逃がし |
hooks/sheet/render-review.ts | 指摘の画面 → 自己完結 HTML。左に文書 (DOMParser で解析し、許可した要素と属性だけで組み直す)、段落に上から番号を振る。右に全体 (指摘と書き換えの数と一覧、全体へのコメント) か、選んだ段落の操作 (チップ、コメント、この段落の指摘、書き換え・削除・移動・追加)。書き換えた段落は左に書き換えた後の文を出す |
hooks/reply/format.ts | 回答 JSON + 質問票 → 回答固定形 v1 |
hooks/reply/summary.ts | 回答固定形 → 畳んだ 1 行の中身 (選んだ案と補足の数、指摘と書き換えの数) |
hooks/review/review-v1.ts | 指摘の画面 (review) と指摘の回答 JSON の型 (指摘と添削)、チップ 9 種 |
hooks/review/validate-review.ts | review の検証 (エラーを全部返す) |
hooks/review/document.ts | 文書の HTML の検査 (構成案と同じ要素、属性は表の colspan と rowspan だけ、10 万文字まで、段落が 1 つ以上)、段落番号を振る要素、画面の JS と同じ規則で段落番号を振る numberedBlocks |
hooks/review/candidates.ts | 指摘の候補: fork に渡す 1 問の組み立て、返答の JSON の取り出しと検証、作るかどうかの判定 |
hooks/review/answer.ts | 指摘の回答 JSON の読み取り (kind、documentId、revision が違えば無視、形の違う指摘と添削は捨てる) |
hooks/review/format.ts | 指摘の回答 JSON → 回答固定形 (## 指摘、## 指摘した段落、## 書き換え) |
hooks/receiver/index.ts | Python 3 の候補 (python3、python、py -3)、receiver.py のサブコマンドの argv (start、start --live、clean、open、stop)、start が印字する 1 行の読み取り、URL (/, /wait, /document, /finish, Link 用の localhost) |
hooks/wait/sync-wait.ts | 同期待ち: tool.call の中で /wait のロングポーリングを繰り返し、回答ファイルを読む (読み方は画面ごとに渡す) |
hooks/views/pane-view.ts | 待機中のペイン (Box / Text / Button / Link) |
hooks/views/reply-row.ts | 畳んだ回答行 (Box / Text。terminal と desktop で同じ木) |
hooks/views/strings.ts | 固定文言 |
hooks/tool-input.d.ts | McpToolInputs にツールの入力を足す宣言 (型付けのみ) |
scripts/receiver.py | ローカル受信サーバ (Python 3 の標準ライブラリのみ、127.0.0.1、/wait のロングポーリング付き) と、OS ごとに違う操作のサブコマンド (start で切り離して起動、open でブラウザ、clean で削除、stop で停止)。--live でライブ表示の受信サーバ (/events の SSE、/document、/comments、/finish。配る HTML の消失か書き換えか、Mod からの接触 (/document、/finish、keepalive の /wait) の 10 分の途絶で自分で終わる。質問票と指摘の画面の受信サーバにある「起動から 1 時間」の上限は付けない。Mod が閉じるときは POST /finish { close: true } で終わる。どの終わり方でも、終わる直前に phase: closed を流して画面の再接続を止める。状態は phase 付きで流し、文言は画面が hooks/views/strings.ts から持つ) |
skills/doc-desk/ | Claude 側の手順 (SKILL.md) と references (下の「スキル」) |
tests/ | claude plugin test のテストと fixtures |
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate plugins/doc-desk の印字:
❯ ./register.ts hooks: session.start, tool.call{tool=mcp__doc-desk__open_form}, tool.call{tool=mcp__doc-desk__open_review}, tool.call{tool=mcp__doc-desk__open_live}, command.run{command=?}, ui.render{component=Pane}, ui.render{component=UserMessage, props.origin has {kind=plugin}}, turn.start, turn.step, turn.complete, tool.call{tool=Write}, tool.call{tool=Edit}, tool.check{tool=Write}, tool.check{tool=Edit}, tool.check{tool=NotebookEdit}, session.compact, session.end, ui.close{id=doc-desk}
command.run は定数 COMMAND_NAME (doc-desk-resume) で絞るので、validate は command=? と印字します。
| event | what the hook does |
|---|---|
session.start | $ を host に束ね、ツール open_form と open_review と open_live と /doc-desk-resume を登録し、Python 3 を python3、python、py -3 の順に 1 回だけ探して結果を保持する。e.cwd を証跡の置き場の基準にする。続けて $.store の pending:<cwd> に前のセッションの待機の記録があれば引き継ぐ (下の「引き継ぎ」) |
tool.call of mcp__doc-desk__open_form | 質問票を検証し (不正なら { status: "invalid", errors })、doc-desk/<label>.json と .html を書き、同じ label の前回の .answer.json を receiver.py clean で消し、受信サーバを receiver.py start で切り離して起動して stdout の 1 行から port と pid を読み、ブラウザを開き (openBrowser: false なら開かない)、ペインを開き、clock.every(500) で回答ファイルの監視を始める。続けて waitSeconds (既定 300、0 で待たない、上限 1800) まで GET /wait?timeout=4 のロングポーリングで回答を待ち、届けば { status: "answered", reply, files } と context 1 件を返す (user turn は投入しない)。上限到達・中断 (next.signal)・受信サーバ喪失・waitSeconds: 0 なら { status: "pending", url, files, wait: { seconds, endedBy } } と context 1 件を返し、以後は監視が届ける。待っている間に [取り消す] が押されれば { status: "cancelled", reason }。受信サーバが起動できなければ { status: "failed", reason, files } |
tool.call of mcp__doc-desk__open_review | review を検証し、doc-desk/<label>.doc.html を読んで検査する (無い、読めない、許可リストに無い要素や属性、10 万文字超、段落なし、のどれかなら { status: "invalid", errors })。続けて、入力の selfReview と設定の selfReview がどちらも false でなければ、段落番号付きの一覧 (numberedBlocks) と document-lint の要約を $.model.fork に 1 問だけ渡し、返った JSON から候補 (最大 5 件、段落番号が範囲内、チップが 9 種のどれか、text が 200 文字以内) を取り出して doc-desk/<label>.candidates.json に書き、画面に埋め、結果の files に candidates として載せる。返答の JSON はコードフェンスの中を先に探し、無ければ [ の位置ごとに試す。fork が答えなければ候補なしで進む。fork を待つ間に中断されたら (next.signal か fork の aborted)、受信サーバもブラウザも出さずに { status: "cancelled" } を返す。候補を作らないときは、前回の .candidates.json を消す。doc-desk/<label>.json に review を、.html に指摘の画面を書き、あとは open_form と同じ (受信サーバ、同期待ち、監視、ペイン)。結果の files は { doc, review, html } (候補を作ったときは candidates、answered では answer と md を足す)。同じ documentId のライブ表示が開いていれば、POST /finish {} で未届のライブ指摘を受け取って候補 (source: "live") として画面に埋め、指摘の画面の受信サーバが立った後に POST /finish { url } でライブ表示のタブを移す (ブラウザは openBrowser: true のときだけ開く)。別の documentId ならライブ表示を POST /finish { close: true } で閉じる (届かなければ pid で止める) |
tool.call of mcp__doc-desk__open_live | 設定 liveView が false なら { status: "disabled" }。live を検証し (不正なら invalid)、回答待ちの画面があれば invalid。前のライブ表示を止め、doc-desk/<label>.json と .html を書き (同じ label の前のセッションの受信サーバは HTML の書き換えで自分で終わる)、receiver.py start --live で起動し、ブラウザとペインを開き、{ status: "opened", documentId, url, files } と context 1 件を返す |
turn.start | main の turn の id を覚える ([今すぐ止めて直す] の $.turn.abort に使う) |
turn.step | ライブ表示が開いていて main の step なら、チャンクを変えずに下流へ流しながら見る。tool チャンクの Write / Edit の input を json-stream.ts で読み、file_path が source なら、Write は replace '' の後に文を改行ごと (改行が無ければ 200 文字ごと) に append で、Edit は「直しています」を送る。tool と stop のチャンクでは status を送って指摘を取りに行く。送信は待たずに投げ、応答の指摘を貯める。応答の stop が true で、まだ止める指摘 (now) が残っていれば、$.clock.after(0) で今のディスパッチの外に出てから $.turn.abort の後に $.prompt.submit で指摘を届ける (turn の id が分からなければ [書き終わったら直す] と同じ扱い) |
tool.call of Write、Edit | ライブ表示が開いていて、書き込み先が source なら、完了後に全文を読み直して replace で送る。まだ届けていない指摘があれば、mode を問わず結果に context を 1 つ足して届け、doc-desk/<label>.comments.json に記録する。ここでは turn を止めない (tool.call の中では $.prompt.submit が拒まれ、止めても指摘が届かないため。Write はもう終わっているので [書き終わったら直す] と同じになる) |
command.run of doc-desk-resume | 起動時に見つけた回答が未送なら、$.clock.after(0) で回答固定形を $.prompt.submit する (command.run の中の prompt.submit はエンジンが拒むため)。そうでなく待機中ならペインを focus 付きで開き直し、ブラウザも開き直す。待機中でなくライブ表示が開いていれば、ペインとブラウザでライブ表示を開き直す。どれでもなければ「待機中の質問票も指摘の画面も、開いているライブ表示もありません」 |
ui.render of Pane (requestId doc-desk) | 見出し (インタビュー: <label> (rev n)、指摘の画面では 指摘: <label> (rev n))、URL (127.0.0.1 の文字)、Link (href は http://localhost:<port>/?t=…。Link の href は https: か http://localhost しか通らない)、経過秒数、[ブラウザで開く (o)] と [取り消す] を描く。回答待ちの画面が無くライブ表示が開いていれば、ライブ表示: <label> と書いている状態を描き、[取り消す] でライブ表示を閉じる |
ui.render of UserMessage (props.origin.kind が plugin) | この Mod (origin.name が doc-desk) が投入した 【doc-desk 回答】 の行を 1 行に畳む。質問票は 【doc-desk 回答】<documentId> Q1=A Q2=お任せ 補足 n 件、指摘の画面は 指摘 n 件 書き換え m 件。2 行目に保存した .md のパス (このセッションで届けたものだけ) と「ctrl+o で全文」。isExpanded (ctrl+o) のとき、固定形として読めないとき、他の plugin や人の行は next(e)。描き換えは行の見え方だけで、モデルが読む文は変わらない |
turn.complete | 待機中で、main の turn (agentId なし) が reason: "answer" で終わったとき、1 つの待機につき 1 回だけ { text: "回答先: <url> (/doc-desk-resume で開き直せます)" } を返して答えの下に出す。ライブ表示が開いていれば、answer で「書き終わりました」(未届の指摘があれば件数を $.ui.log)、aborted で「中断しました」を送る ([今すぐ止めて直す] で止めた turn は除く) |
tool.check of Write、Edit、NotebookEdit | 待機中で、実際の呼び出し (tool_use_id あり) の書き込み先 (file_path、notebook_path) が、質問票の source、または指摘の画面の review.source か doc-desk/<label>.doc.html と同じなら { decision: "deny", reason: "doc-desk: <label> の回答待ちなので、<path> への書き込みを止めました。この文書への書き込みをやめてターンを終え、回答が届くのを待ってください …" } を返す (reason はモデルへの指示。全文は hooks/views/strings.ts の guardReasonOf)。起動時に見つけた回答をまだ送っていない間 (未送) も同じパスを止め、reason で「/doc-desk-resume で回答を送ってもらう」よう伝える (guardUnsentReasonOf)。まだ無いファイルは、親のフォルダの realPath に名前を足して比べ、シンボリックリンクのフォルダ越しの新規作成も拾う。パスは cwd 基準の絶対パスにし、\ を / に、.. を解決して比べる (Windows では大文字小文字を無視)。ファイルがあれば $.fs.stat(path, { resolve: true }) の realPath でも比べ、シンボリックリンク越しの書き込みも拾う。それ以外は next(e)。ask でなく deny なのは、人を待たせず、止める理由はモデルに伝われば足りるため |
session.compact | main の会話 (agentId なし) で、このセッションで Claude に届けた回答があるときだけ動く。next({ ...e, instructions }) で要約に「doc-desk の決定は省略しない」を足し、戻った messages の末尾に 【doc-desk 決定の記録】 と各 doc-desk/<label>.md の全文を user の message (handle なし) として足す。回答待ちの画面があれば「届くまで対象の文書を書かない」と回答先の URL (圧縮で Tool result と turn.complete の行が消えても案内できるように) も足す。前の圧縮 (precompute の再利用を含む) で足した記録が結果に残っていれば除いてから足すので、記録は常に 1 つ。合計 20,000 文字 (JavaScript の文字列の長さ。UTF-16 の単位で数えるので、絵文字などは 1 字を 2 と数える) を超えると各回答を決定の部分 (質問票は Qn. の行と ## 表、指摘の画面は ## 指摘 と書き換えの見出し) にし、それでも超えれば新しいものから入れて残りはパスだけ書く。trigger が precompute でも同じ |
session.end | このセッションが待機の記録を持っていれば、その heartbeatAtMs を 0 に戻して lease を手放す (次のセッションが 90 秒待たずに引き継げる)。reason が clear か resume のときはプロセスが続き監視も続くので、手放さない (手放すと、次の heartbeat までの間に同じフォルダの別のセッションが引き継ぎ、両方で回答を届けてしまう)。ライブ表示が開いていれば「セッションが終わりました」を待たずに送るだけで、受信サーバは止めない (Mod からの接触が 10 分途絶えると自分で終わる。pid と token はどこにも残さない) |
ui.close of doc-desk | 人が閉じても監視は続け、状態行に「/doc-desk-resume で開き直せます」を出す |
監視タイマーは同期待ちの間 (Pending.isSyncWaiting) は回答を届けず、経過秒数の更新だけ行います。同期待ちを抜けたときにフラグを下ろすので、同じ回答が Tool result と user turn の両方で届くことはありません。
引き継ぎ: 待機を始めると $.store の pending:<cwd>:<セッション id> に { kind, label, documentId, revision, token, port, pid, startedAtMs, sessionId, heartbeatAtMs } を書き、回答が届くか取り消すと消します。$.store は plugin ごとに 1 つで、プロジェクトをまたいで共有されるので cwd をキーに含めます (区切りを / にそろえ、末尾の / を外し、Windows では小文字にします)。同じフォルダで同時に動くセッションが互いの記録を上書きしないよう、キーはセッションごとに分けます。記録を持っているセッションは 30 秒ごとに heartbeatAtMs を進めます。
次の session.start では、$.store.keys() から同じフォルダの記録を探し、次の順に見ます。
| 状態 | すること |
|---|---|
| 別のセッションが今も持っている (持ち主の id が違い、heartbeat が 90 秒以内) | 引き継がず、消しもせず、$.ui.log で 1 回だけ伝える (両方に回答が届いたり、片方の取り消しで相手の受信サーバを止めたりしない)。持ち主がクラッシュして session.end が来なかったときに備え、lease が切れる頃にもう一度見る (持ち主が生きていれば lease が延びているので、また待つ) |
| 形が違う | 記録を消す |
| 残り (持ち主のいない記録) | 待機を始めたのが新しい順に試す。7 日より古いものと証跡の無いものは消して次を試す。最初に使えるものを、先にこのセッションのキーへ書いてから前のキーを消して移し (逆の順だと、その間に lease の切れた持ち主の heartbeat が書き直し、両方が記録を持ってしまう)、下の順に見る。それより古い記録は、差し替わったものとして消し、$.ui.log で伝える (後日また引き継がない) |
.answer.json がある | 固定形を .md に書く。e.surface が null (-p、SDK) なら $.prompt.submit で届け、受け付けられてから記録を消す (その前にプロセスが終わっても次の起動でまた届ける)。人がいれば $.ui.log と $.ui.toast で知らせ、$.prompt.suggest で /doc-desk-resume を候補に出し、記録は /doc-desk-resume で送るまで残す (送れなければ未送に戻す) |
受信サーバが生きている (GET /wait?timeout=0 が {"answered":false}) | 監視を再開する。ブラウザもペインも開かず、$.ui.status に「前回の質問票 <label> が未回答です」(指摘の画面なら「前回の指摘の画面 …」) を出す |
| 受信サーバに届かない | 同じ token と --port <記録の port> で receiver.py start を呼び、監視を再開する。同じ port を取れなかったら生死をもう一度見て、古い受信サーバが生きていれば (さっきの確認は一時的な失敗)、起動し直した方を止めて古い方を使う。古い方の pid は他のプロセスに使い回されているかもしれないので止めない。古い方も死んでいれば新しい URL を $.ui.log で伝える。.html が消えていれば書き直す |
このセッションが止まっている間に lease (90 秒) が切れ、別のセッションが記録を引き継いだときは、heartbeat で「自分のキーが消え、同じ token の記録が別のセッションのキーにある」ことに気付き、受信サーバは止めずに手を引きます (同期待ちの最中なら cancelled の理由は「別のセッションが引き継ぎました」)。自分のキーが消えていても引き継がれていなければ (書き込みの失敗など)、記録を書き直して待ち続けます。
$.prompt.submit は、他のフックに断られると reject せず { drop } で resolve します。これも受け付けられなかったものとして扱い、記録を消さず、未送として /doc-desk-resume で送り直せるようにします (監視が届けるときも同じ)。同じ label の画面を出し直したときは、起動時に見つけた同じ label の未送の回答を捨てます (回答ファイルが消えて古くなるため)。
回答 JSON は user turn の隠し context には添えません。Claude Code 2.1.278 では plugin 自身の prompt.submit フックがその plugin の $.prompt.submit を見ないため (実測、plan.md 4 章 V7)、回答 JSON は doc-desk/<label>.answer.json を読んで照合します。
$validate の印字:
❯ ./register.ts calls: $.clock.after, $.clock.every, $.clock.now, $.command.register, $.fs.exists, $.fs.read, $.fs.stat, $.fs.write, $.http.fetch, $.model.fork, $.process.run, $.prompt.submit, $.prompt.suggest, $.session.id, $.store.delete, $.store.get, $.store.keys, $.store.set, $.tool.register, $.turn.abort, $.ui.close, $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve, $.ui.status, $.ui.toast
clock.after (/doc-desk-resume の後に未送の回答を送る), clock.every (回答の監視と、待機の記録の heartbeat), clock.now, command.register, fs.exists, fs.read, fs.stat (書き込みの照合の realPath), fs.write, http.fetch (同期待ちの GET /wait?t=…&timeout=4 と、引き継ぎの生死確認 timeout=0、ライブ表示の POST /document と POST /finish。127.0.0.1 の受信サーバへ), model.fork (open_review の中で、指摘の候補を 1 問だけ聞く), process.run (<python> --version、<python> receiver.py の clean / start / open / stop。シェルは使わない), prompt.submit, prompt.suggest (起動時に届いていた回答を送る /doc-desk-resume を候補に出す), session.id (待機の記録の持ち主), store.get / store.set / store.delete / store.keys (待機の記録 pending:<cwd>:<セッション id>), tool.register, turn.abort (ライブ指摘の [今すぐ止めて直す]), ui.close, ui.invalidate, ui.log, ui.open, ui.resolve, ui.status, ui.toast (引き継いだ回答の案内と、回答が届いたときの「回答を受け取りました: <label>」)。 $.plugin.root も読みます (呼び出しではないので印字されません)。
$.process.run は型定義で「CLI only」とされています。ここでの CLI は、ローカルで動く Claude Code のプロセスを指すと 読んでいます。Claude Code Desktop もローカルの Claude Code を動かすので、受信サーバの起動、ブラウザを開く、停止、削除は Desktop でも動きました (2026-09-23 確認、answered まで通過。フォームは既定のブラウザで開く)。 Mod はシェルを使わず、receiver.py のサブコマンドだけを呼ぶので、Windows でも同じ argv で動きます (sh、nohup、rm、kill、xdg-open は Windows のプロセスからは見つかりません)。 Desktop では ~/.claude/settings.json の env に CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 と CLAUDE_CODE_PLUGIN_DIRS=<このフォルダの絶対パス> を書いて読み込ませます。 Desktop ではペイン ($.ui.open) が描かれませんでした。/doc-desk-resume で人が求めて開いても出ません (2026-09-23 確認。 原因は未確定)。ブラウザは自動で開き、URL は Tool result にも載るので、Desktop ではペインに頼らずに使えます。 待機中に止めると、モデルには「ツールの実行が拒否された」と見え、pending は届きません。受信サーバと監視は 残るので、後から送った回答は user turn として届きます。
同期待ちとフック予算: フックの予算は 1 ディスパッチ 10 秒ですが、$ 呼び出しの待ち中は時計が止まります ($.clock の待ちは例外で、予算に数えられます)。そのため同期待ちは $.clock.sleep のポーリングではなく、 $.http.fetch で受信サーバの /wait を 4 秒ずつ保留させて待ちます。4 秒なのは、Esc で next.signal が abort したあとフックが動けるのが 5 秒 (lingerMs) だからです。経過秒数は時計を読まず、要求した timeout の合計で数えます。
セッションの cwd の下の doc-desk/ に、質問票と指摘の画面の label ごとに書きます。
| ファイル | 中身 |
|---|---|
doc-desk/<label>.json | 質問票 (検証済み)。指摘の画面では review (検証済み) |
doc-desk/<label>.doc.html | 指摘の画面に出す文書の HTML (Claude が書き、Mod は読むだけで消さない) |
doc-desk/<label>.html | HTML シート (トークンは埋めない。ブラウザの JS が URL の ?t= から読む) |
doc-desk/<label>.candidates.json | 指摘の画面に出した Claude の候補 (selfReview が有効なときだけ。無ければ []) |
doc-desk/<label>.answer.json | ブラウザが POST した回答 JSON (open_form は同じ label の前回のものを起動前に消す) |
doc-desk/<label>.md | 回答固定形 (Claude に送ったものと同じ) |
doc-desk/<label>.json、.html (ライブ表示) | open_live の live (検証済み) と、ライブ表示の HTML (文書の中身とトークンは含まない) |
doc-desk/<label>.comments.json (ライブ表示) | ライブ指摘と、その扱い (context / prompt / review / dropped) と時刻 |
skills/doc-desk/SKILL.md が Claude 側の手順です。設計書・仕様書・企画書・記事を書く (更新する) 依頼で発動し、現物把握 → 構成案を書き、論点を 3±1 問に圧縮して構成案に印で置く → 質問文と構成案の自己検査 → 文脈ゼロの subagent への試問 → open_form → 回答の反映と文書の検査 → (宣言があれば) 文書を HTML にして open_review → 指摘の反映、の順に進めます。Mod は描画と回収だけを担います。
| ファイル | 中身 |
|---|---|
skills/doc-desk/SKILL.md | 手順、禁則、open_form の結果 (answered / pending / cancelled / invalid / failed) ごとの動き、Mod が無いときの案内、証跡と完了報告 |
references/form-spec-v1.md | 質問票 JSON の書き方 (hooks/form/validate.ts の全規則とエラー文、構成案の HTML の規則、完全な例) |
references/reply-format-v1.md | 回答固定形 v1 の契約と読み方 (hooks/reply/format.ts のゴールデンと一致) |
references/question-lint.md | ja-text-communication の規範番号順の自己検査表 |
references/document-lint.md | 回答を反映して書く文書の検査表。ja-text-communication の規範と、stop-ai-slop-jp と humanizer-ja から選んだ AI 臭の検査 (S1〜S24)、採用しなかった規則と理由 |
references/preflight.md | 試問の 5 問、質問票の Markdown の形、subagent のプロンプト雛形、打ち切り規則 |
references/review-mode.md | 指摘モード。宣言の判定、文書の HTML の書き方、open_review の入力と結果、長い文書の分け方、指摘の回答 (hooks/review/format.ts と一致)、反映と完了報告 |
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/doc-desk
対話モードで次のように頼みます。
plugins/doc-desk/tests/fixtures/spec-auth-01.form.json を読んで、その JSON を form にして open_form ツールを呼んでください。回答が届くまで待ってください。
ブラウザが開くので、選択肢を選んで [送信] を押します。5 分 (waitSeconds の既定) 以内なら ツールの結果 (status: "answered") として Claude に届き、そのまま文書の作成に進みます。 5 分を過ぎるか Esc で中断すると status: "pending" でターンが終わり、あとで [送信] を押したときに 【doc-desk 回答】spec-auth-01 で始まる user turn が届きます。 ブラウザを閉じてしまったら /doc-desk-resume で開き直せます。
指摘の画面は、次のように頼んで試せます。
次の HTML を doc-desk/spec-auth-01-review.doc.html に書き出して、review に schemaVersion 1、documentId spec-auth-01、revision 1、label spec-auth-01-review、title 「認証方式の仕様」を渡して open_review ツールを呼んでください:
<h2>認証方式</h2><p>認証は OIDC に統一します。</p>
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate plugins/doc-desk
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/doc-desk
npx -y -p typescript tsc -p plugins/doc-desk --noEmit
tsc はリポジトリ直下の .claude/types/ (claude -p "/plugin-types" が生成、git には入れない) を読みます。
Windows では、エンジンが $.fs に渡したパスを C:\work\doc-desk\x.json の形に直してフックへ渡します。 テストの偽装 (tests/fixtures/world.ts) は、パスを /work/doc-desk/x.json の形にそろえてから照合します。 process.run の argv は直されないので、そのまま照合します。
受信サーバ単体:
python3 plugins/doc-desk/scripts/receiver.py start --token t \
--html doc-desk/spec-auth-01.html --out /tmp/x.answer.json # 切り離して起動し {"port": n, "pid": n} を 1 行出す (serve なら前面で動かす)
curl "http://127.0.0.1:<port>/?t=t" # HTML (トークン無しは 403)
curl "http://127.0.0.1:<port>/wait?t=t&timeout=4" # 回答の POST か 4 秒まで保留 → {"answered":true|false}
curl -X POST -d '{"answers":{}}' "http://127.0.0.1:<port>/answer?t=t" # 書いて、保留中の /wait に応答してから終了
python3 plugins/doc-desk/scripts/receiver.py stop <pid> # 止める (もう無ければ何もしない)
python3 plugins/doc-desk/scripts/receiver.py clean /tmp/x.answer.json # 消す
Windows で python3 が Microsoft Store の案内に当たるときは、python か py -3 に読み替えます (Mod もこの順に探します)。
<port> と <pid> は start が出した 1 行の値です。--port <n> を足すとその port を使い、塞がっていれば OS に選ばせます。 Windows では SO_REUSEADDR を付けず SO_EXCLUSIVEADDRUSE で listen するので、使用中の port を横取りしません (2026-09-24 確認: 使用中の port を指定すると別の port になり、TIME_WAIT だけ残る port は取り直せる)。
書き込みを止める範囲の限界: tool.check で見るのは Write、Edit、NotebookEdit の書き込み先だけです。Bash の heredoc やリダイレクト (cat > docs/auth.md) は拾いません。大文字小文字をそろえて比べるのは Windows のパス (ドライブ名で始まる) だけなので、macOS の既定のファイルシステムのように大文字小文字を区別しない所で、ファイルがまだ無いときに `Docs/Auth.
hooks/register.ts 1380 lines1import type { On, PluginOptions, Timer } from 'claude-code'
2
3import { parseAnswer } from './form/answer'
4import { questionsOf, type FormV1 } from './form/form-v1'
5import { LIVE_INPUT_SCHEMA, REVIEW_INPUT_SCHEMA, TOOL_INPUT_SCHEMA } from './form/schema'
6import { validateForm } from './form/validate'
7import { buildDecisionRecord, isDecisionRecord, type SettledReply } from './compact/record'
8import { isSamePath, normalizePath, writeTargetOf } from './guard/paths'
9import type { Host } from './host'
10import { createLiveController, isLiveViewEnabled } from './live/controller'
11import {
12 COMMAND_NAME,
13 EVIDENCE_DIR,
14 LIVE_TOOL_NAME,
15 PANE_ID,
16 PLUGIN_NAME,
17 REVIEW_TOOL_NAME,
18 TOOL_NAME,
19} from './names'
20import {
21 cleanupArgv,
22 removeArgv,
23 isPython3,
24 linkUrlOf,
25 openBrowserArgv,
26 parseStartOutput,
27 PYTHON_CANDIDATES,
28 receiverArgv,
29 stopArgv,
30 urlOf,
31 waitUrlOf,
32 type ReceiverInfo,
33} from './receiver'
34import { formatReply } from './reply/format'
35import { summarizeReply, type ReplySummary } from './reply/summary'
36import { parseReviewAnswer } from './review/answer'
37import { candidatePrompt, parseCandidates, wantsSelfReview, type ReviewCandidate } from './review/candidates'
38import { documentErrors, numberedBlocks } from './review/document'
39import { formatReviewReply } from './review/format'
40import type { ReviewV1 } from './review/review-v1'
41import { validateReview } from './review/validate-review'
42import { renderHtml } from './sheet/render-html'
43import { renderReviewHtml } from './sheet/render-review'
44import {
45 HEARTBEAT_INTERVAL_MS,
46 isOwnedByOther,
47 LEASE_MS,
48 isStale,
49 parsePendingRecord,
50 recordKeyOf,
51 recordPrefixOf,
52 type PendingRecord,
53} from './store/pending-record'
54import { paneView } from './views/pane-view'
55import { replyRow } from './views/reply-row'
56import { STRINGS } from './views/strings'
57import { clampWaitSeconds, waitForAnswer } from './wait/sync-wait'
58
59/**
60 * 回答の監視間隔 (ms)。
61 */
62const WATCH_INTERVAL_MS = 500
63
64/**
65 * ペインの経過秒数を更新する間隔 (監視の回数)。500 ms × 10 = 5 秒。
66 */
67const REDRAW_EVERY_TICKS = 10
68
69/**
70 * 証跡ファイルの置き場。`doc` は指摘の画面で Claude が書き出す文書の HTML です。
71 */
72type Paths = {
73 form: string
74 html: string
75 answer: string
76 md: string
77 doc: string
78 /** 指摘の画面に出した Claude の候補 (証跡) */
79 candidates: string
80}
81
82/**
83 * 待機が終わった理由 (同期待ちの結果 `cancelled` の reason に使います)。
84 */
85type DropReason = 'cancelled' | 'replaced' | 'released'
86
87/**
88 * 人に出す画面 1 つ分。質問票 (`open_form`) と指摘の画面 (`open_review`) の違いはここに閉じ込めます。
89 */
90type Sheet = {
91 kind: PendingRecord['kind']
92 documentId: string
93 revision: number
94 label: string
95 /** ペインの 1 行目 */
96 heading: string
97 paths: Paths
98 /** 回答ファイルの中身を回答固定形にする。この画面の回答でなければ null */
99 replyOf: (text: string) => string | null
100 /** 結果の `files` (answered では answer と md を足す) */
101 files: Record<string, string>
102 /** `answered` と `pending` の結果に添える context */
103 answeredContext: string
104 pendingContext: string
105 /** 回答待ちの間、Write / Edit / NotebookEdit を止めるパス (書いたまま。照合のときに正規化する) */
106 guardedPaths: string[]
107 /** 回答固定形を畳んだ 1 行の中身 (質問票は問いの題が分かるので、題に紛らわしい文字列があっても読める) */
108 summaryOf: (reply: string) => ReplySummary | null
109}
110
111/**
112 * 待機中の画面 1 つ分の状態。
113 */
114type Pending = {
115 sheet: Sheet
116 receiver: ReceiverInfo
117 token: string
118 url: string
119 linkUrl: string
120 startedAtMs: number
121 timer: Timer
122 ticks: number
123 isChecking: boolean
124 /** `tool.call` の中で回答を待っている間 true。監視タイマーはその間 回答を届けない */
125 isSyncWaiting: boolean
126 /** `dropPending` で片付けられたときの理由 */
127 dropReason: DropReason | null
128 /** `turn.complete` で回答先の URL を添えたか (1 つの待機につき 1 回) */
129 isUrlShown: boolean
130}
131
132/**
133 * 起動時に見つけた、まだ Claude に送っていない回答。`/doc-desk-resume` で送ります。
134 */
135type Unsent = {
136 sheet: Sheet
137 reply: string
138}
139
140/**
141 * 質問票の画面を組みます。
142 */
143function sheetOfForm(form: FormV1, paths: Paths): Sheet {
144 return {
145 kind: 'form',
146 documentId: form.documentId,
147 revision: form.revision,
148 label: form.label,
149 heading: STRINGS.headerOf(form.label, form.revision),
150 paths,
151 replyOf: text => {
152 const answer = parseAnswer(text, form)
153 return answer ? formatReply(form, answer) : null
154 },
155 summaryOf: reply => summarizeReply(reply, questionsOf(form).map(question => question.title)),
156 files: { form: paths.form, html: paths.html },
157 answeredContext: STRINGS.answeredContext,
158 pendingContext: STRINGS.toolContext,
159 guardedPaths: form.source === undefined ? [] : [form.source],
160 }
161}
162
163/**
164 * 指摘の画面を組みます。
165 */
166function sheetOfReview(review: ReviewV1, paths: Paths, hasCandidates: boolean): Sheet {
167 return {
168 kind: 'review',
169 documentId: review.documentId,
170 revision: review.revision,
171 label: review.label,
172 heading: STRINGS.reviewHeaderOf(review.label, review.revision),
173 paths,
174 replyOf: text => {
175 const answer = parseReviewAnswer(text, review)
176 return answer ? formatReviewReply(review, answer) : null
177 },
178 summaryOf: reply => summarizeReply(reply),
179 files: { doc: paths.doc, review: paths.form, html: paths.html, ...(hasCandidates && { candidates: paths.candidates }) },
180 answeredContext: STRINGS.reviewAnsweredContext,
181 pendingContext: STRINGS.reviewPendingContext,
182 guardedPaths: [...(review.source === undefined ? [] : [review.source]), paths.doc],
183 }
184}
185
186const dateOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
187
188/**
189 * `$.prompt.submit` の結果が、投入を受け付けたものか (`{ drop }` で resolve したら受け付けられていない)。
190 */
191const isAccepted = (result: { drop?: string }): boolean => result.drop === undefined
192
193/**
194 * doc-desk Mod のフックを登録します。
195 *
196 * 状態機械 (open_review も open_form と同じ):
197 *
198 * ```
199 * idle --open_form--> waiting(sync) --回答検知 (tool.call の中)--> idle (結果 answered)
200 * waiting(sync) --waitSeconds 到達 / 中断 / 受信サーバ喪失--> waiting(async) (結果 pending)
201 * waiting(async) --回答検知 (監視タイマー)--> submitting --prompt.submit 済--> idle
202 * waiting --open_form / open_review (別の画面)--> 前の受信サーバを kill、監視停止 → 新しい waiting
203 * waiting --[取り消す]--> kill、監視停止、ペイン閉 → idle (同期待ち中なら結果 cancelled)
204 * waiting --ui.close (人)--> waiting のまま (監視は続く)
205 * waiting --/doc-desk--> ペインとブラウザを開き直す
206 * ```
207 *
208 * 待機は `$.store` の `pending:<cwd>` にも記録し、次の `session.start` で引き継ぎます:
209 *
210 * ```
211 * 記録あり + 回答ファイルあり --人がいる--> unsent (/doc-desk-resume で送る)
212 * ---p / SDK--> prompt.submit で届ける
213 * 記録あり + 受信サーバが生きている --> waiting(async) (ブラウザもペインも開かない)
214 * 記録あり + 受信サーバが死んでいる --> 同じ port と token で起動し直して waiting(async)
215 * 記録が古い (7 日超) / 証跡が無い --> 記録を消す
216 * ```
217 *
218 * @param on エンジンの登録関数
219 * @param options plugin.json の `userConfig` の値 (`selfReview`: 指摘の画面に Claude の候補を出すか。既定 true)
220 */
221export function register(on: On, options: PluginOptions = {}) {
222 let host: Host | null = null
223 let cwd = ''
224 /** このセッションの id (`$.session.id()`)。待機の記録の持ち主に書く */
225 let sessionId = ''
226 /** このセッションが持っている待機の記録。heartbeat で時刻を進める */
227 let ownedRecord: PendingRecord | null = null
228 /** 「別のセッションが待っている」を案内した記録の token (lease が切れる頃にもう一度見るとき、案内を繰り返さない) */
229 const announcedOwnedByOther = new Set<string>()
230 let pythonProbe: Promise<readonly string[] | null> | null = null
231 let pending: Pending | null = null
232 let unsent: Unsent | null = null
233 let isPaneOpen = false
234 let elapsedSeconds = 0
235 /** このセッションで届けた回答固定形 → 保存した `.md` (畳んだ回答行に出す) */
236 const mdPathOfReply = new Map<string, string>()
237 /** このセッションで届けた回答固定形 → 畳んだ 1 行 (質問票の題を知っている画面で作ったもの) */
238 const summaryOfReply = new Map<string, ReplySummary | null>()
239 /** このセッションで Claude に届けた回答 (古い順、label ごとに最新の 1 つ)。圧縮で原文を差し戻す */
240 let settled: { label: string; mdPath: string }[] = []
241
242 const rememberSettled = (sheet: Sheet) => {
243 settled = [...settled.filter(entry => entry.label !== sheet.label), { label: sheet.label, mdPath: sheet.paths.md }]
244 }
245
246 const pathsOf = (label: string): Paths => {
247 const base = `${cwd}/${EVIDENCE_DIR}/${label}`
248 return {
249 form: `${base}.json`,
250 html: `${base}.html`,
251 answer: `${base}.answer.json`,
252 md: `${base}.md`,
253 doc: `${base}.doc.html`,
254 candidates: `${base}.candidates.json`,
255 }
256 }
257
258 /**
259 * 受信サーバと照合するトークン。ローカルの一回限りなので乱数で足ります。
260 */
261 const tokenOf = (nowMs: number): string =>
262 `${nowMs.toString(16)}${Math.random().toString(16).slice(2, 10)}${Math.random().toString(16).slice(2, 10)}`
263
264 /**
265 * Python 3 を起動する argv を `PYTHON_CANDIDATES` の順に 1 回だけ探し、結果を保持します。
266 * どれも Python 3 でなければ null です。
267 */
268 function pythonOf(engine: Host): Promise<readonly string[] | null> {
269 pythonProbe ??= (async () => {
270 for (const candidate of PYTHON_CANDIDATES) {
271 const result = await engine.run([...candidate, '--version'], { timeoutMs: 10000 }).catch(() => null)
272 if (result && isPython3(result)) {
273 return candidate
274 }
275 }
276 return null
277 })()
278 return pythonProbe
279 }
280
281 /**
282 * 待機を `$.store` に記録します (次の `session.start` で引き継ぐため)。書けなくても待機は続けます。
283 */
284 async function saveRecord(engine: Host, current: Pending) {
285 await claimRecord(engine, {
286 kind: current.sheet.kind,
287 label: current.sheet.label,
288 documentId: current.sheet.documentId,
289 revision: current.sheet.revision,
290 token: current.token,
291 port: current.receiver.port,
292 pid: current.receiver.pid,
293 startedAtMs: current.startedAtMs,
294 sessionId,
295 heartbeatAtMs: 0,
296 })
297 }
298
299 /**
300 * 記録をこのセッションのものとして書きます (持ち主の id と heartbeat の時刻を入れる)。
301 */
302 async function claimRecord(engine: Host, record: PendingRecord) {
303 ownedRecord = { ...record, sessionId, heartbeatAtMs: await engine.now() }
304 await engine.storeSet(recordKeyOf(cwd, sessionId), ownedRecord).catch(() => undefined)
305 }
306
307 /**
308 * このセッションの記録を消します。同じフォルダの別のセッションの記録には触りません。
309 */
310 async function forgetRecord(engine: Host) {
311 ownedRecord = null
312 await engine.storeDelete(recordKeyOf(cwd, sessionId)).catch(() => undefined)
313 }
314
315 /**
316 * このセッションが記録を持っていれば heartbeat の時刻を進めます。
317 *
318 * 自分のキーの記録が消えていて、同じ token の記録が別のセッションのキーにあれば (このセッションが止まっている
319 * 間に lease が切れ、別のセッションが引き継いだ)、受信サーバは止めずに手を引きます。そうでなければ
320 * (書き込みの失敗などで消えただけ) 記録を書き直し、届け手がいなくならないようにします。
321 */
322 async function heartbeat(engine: Host) {
323 const owned = ownedRecord
324 if (!owned) {
325 return
326 }
327 const key = recordKeyOf(cwd, sessionId)
328 const stored = await engine.storeGet(key).catch(() => null)
329 if (ownedRecord !== owned) {
330 return
331 }
332 if (stored === undefined && (await isTakenOver(engine, owned))) {
333 if (ownedRecord !== owned) {
334 return
335 }
336 ownedRecord = null
337 unsent = null
338 const current = pending
339 if (current && current.token === owned.token) {
340 pending = null
341 current.dropReason = 'released'
342 current.timer.cancel()
343 engine.status(undefined)
344 await closePane(engine)
345 }
346 engine.uiLog(STRINGS.ownedByOtherOf(owned.kind, owned.label))
347 return
348 }
349 ownedRecord = { ...owned, heartbeatAtMs: await engine.now() }
350 await engine.storeSet(key, ownedRecord).catch(() => undefined)
351 }
352
353 /**
354 * 同じフォルダの別のセッションのキーに、同じ token の記録があるか (その待機が引き継がれたか)。
355 */
356 async function isTakenOver(engine: Host, owned: PendingRecord): Promise<boolean> {
357 const prefix = recordPrefixOf(cwd)
358 const mine = recordKeyOf(cwd, sessionId)
359 for (const key of await engine.storeKeys().catch(() => [] as string[])) {
360 if (!key.startsWith(prefix) || key === mine) {
361 continue
362 }
363 const record = parsePendingRecord(await engine.storeGet(key).catch(() => undefined))
364 if (record && record.token === owned.token && record.sessionId !== sessionId) {
365 return true
366 }
367 }
368 return false
369 }
370
371 /**
372 * 同じフォルダの記録のうち、このセッションが引き継げるものを探します。別のセッションが今も持っているものは
373 * 案内だけ出して飛ばし、形の違うものは消します。残りを待機を始めたのが新しい順に、キーと一緒に返します。
374 */
375 async function recordToCarryOver(
376 engine: Host,
377 ): Promise<{ candidates: { key: string; record: PendingRecord }[]; leaseEndMs: number | null }> {
378 const prefix = recordPrefixOf(cwd)
379 const keys = (await engine.storeKeys().catch(() => [] as string[])).filter(key => key.startsWith(prefix))
380 const nowMs = await engine.now()
381 const free: { key: string; record: PendingRecord }[] = []
382 let leaseEndMs: number | null = null
383 for (const key of keys) {
384 const record = parsePendingRecord(await engine.storeGet(key).catch(() => undefined))
385 if (!record) {
386 await engine.storeDelete(key).catch(() => undefined)
387 continue
388 }
389 if (isOwnedByOther(record, sessionId, nowMs)) {
390 // 同じフォルダで動いている別のセッションが待っている。両方で届けたり、取り消しで相手の受信サーバを止めたりしない。
391 // 案内は記録ごとに 1 回だけ出す (lease が切れる頃にもう一度見るので)
392 if (!announcedOwnedByOther.has(record.token)) {
393 announcedOwnedByOther.add(record.token)
394 engine.uiLog(STRINGS.ownedByOtherOf(record.kind, record.label))
395 }
396 const end = record.heartbeatAtMs + LEASE_MS
397 leaseEndMs = leaseEndMs === null ? end : Math.min(leaseEndMs, end)
398 continue
399 }
400 free.push({ key, record })
401 }
402 free.sort((a, b) => b.record.startedAtMs - a.record.startedAtMs)
403 return { candidates: free, leaseEndMs }
404 }
405
406 function openBrowser(engine: Host, url: string) {
407 void pythonOf(engine)
408 .then(python => python && engine.run(openBrowserArgv(python, engine.pluginRoot, url), { timeoutMs: 10000 }))
409 .catch(() => undefined)
410 }
411
412 async function openPane(engine: Host, focus: boolean) {
413 await engine
414 .openPane({ id: PANE_ID, title: STRINGS.paneTitle, ...(focus && { focus: true }) })
415 .catch(() => undefined)
416 isPaneOpen = true
417 }
418
419 async function closePane(engine: Host) {
420 if (!isPaneOpen) {
421 return
422 }
423 isPaneOpen = false
424 await engine.closePane({ id: PANE_ID }).catch(() => undefined)
425 }
426
427 /**
428 * 待機中の画面を片付けます: 受信サーバを止め、監視を止め、ペインを閉じ、記録を消します。
429 * 同期待ちの最中なら、次の周回で `dropReason` を見て `cancelled` を返します。
430 */
431 async function dropPending(engine: Host, reason: DropReason) {
432 const current = pending
433 if (!current) {
434 return
435 }
436 pending = null
437 current.dropReason = reason
438 current.timer.cancel()
439 engine.status(undefined)
440 await forgetRecord(engine)
441 const python = await pythonOf(engine)
442 if (python) {
443 await engine
444 .run(stopArgv(python, engine.pluginRoot, current.receiver.pid), { timeoutMs: 5000 })
445 .catch(() => undefined)
446 }
447 await closePane(engine)
448 }
449
450 /**
451 * 回答固定形を `<label>.md` に書き、ペインを閉じ、記録を消します。
452 * 同期経路はこの文を Tool result で、非同期経路は `prompt.submit` で Claude に届けます。
453 */
454 async function settle(engine: Host, sheet: Sheet, reply: string) {
455 await writeReply(engine, sheet, reply)
456 engine.status(undefined)
457 await forgetRecord(engine)
458 await closePane(engine)
459 engine.uiLog(STRINGS.receivedOf(sheet.paths.md))
460 engine.toast(STRINGS.receivedToastOf(sheet.label))
461 }
462
463 /**
464 * 回答固定形を `<label>.md` に書き、畳んだ回答行のために覚えます。
465 */
466 async function writeReply(engine: Host, sheet: Sheet, reply: string) {
467 await engine.writeFile(sheet.paths.md, `${reply}\n`)
468 mdPathOfReply.set(reply, sheet.paths.md)
469 summaryOfReply.set(reply, sheet.summaryOf(reply))
470 }
471
472 /**
473 * 回答ファイルがあり、この画面の回答として読めれば回答固定形を返します。
474 */
475 async function readReply(engine: Host, sheet: Sheet): Promise<string | null> {
476 if (!(await engine.exists(sheet.paths.answer))) {
477 return null
478 }
479 return sheet.replyOf(await engine.readFile(sheet.paths.answer).catch(() => ''))
480 }
481
482 /**
483 * 監視タイマーの 1 回分。回答ファイルがあれば読んで `prompt.submit` で届けます。
484 * 同じ回答を 2 回届けないよう、届ける前に `pending` を消し、タイマーを止めます。
485 * 同期待ち中 (`isSyncWaiting`) は経過秒数の更新だけ行い、回答は `tool.call` 側に任せます。
486 */
487 function tick(engine: Host, current: Pending) {
488 if (pending !== current || current.isChecking) {
489 return
490 }
491 current.isChecking = true
492
493 void (async () => {
494 current.ticks += 1
495 if (current.ticks % REDRAW_EVERY_TICKS === 0) {
496 elapsedSeconds = Math.max(0, Math.floor(((await engine.now()) - current.startedAtMs) / 1000))
497 if (isPaneOpen) {
498 engine.invalidate()
499 }
500 }
501
502 if (current.isSyncWaiting) {
503 return
504 }
505 const reply = await readReply(engine, current.sheet)
506 if (reply === null || pending !== current || current.isSyncWaiting) {
507 return
508 }
509
510 pending = null
511 current.timer.cancel()
512 await settle(engine, current.sheet, reply)
513 const submitted = await engine.submitPrompt({ text: reply }).catch(() => null)
514 if (submitted !== null && isAccepted(submitted)) {
515 rememberSettled(current.sheet)
516 } else {
517 // 投入が受け付けられなかった。回答を失わないよう、未送として記録を戻し、/doc-desk-resume で送れるようにする
518 if (!pending) {
519 unsent = { sheet: current.sheet, reply }
520 await saveRecord(engine, current)
521 }
522 engine.uiLog(STRINGS.unsentFailedOf(current.sheet.kind, current.sheet.label))
523 engine.toast(STRINGS.unsentFailedOf(current.sheet.kind, current.sheet.label))
524 void engine.suggest({ text: `/${COMMAND_NAME}` }).catch(() => undefined)
525 }
526 })()
527 .catch((error: unknown) => {
528 engine.uiLog(`インタビューの回答を処理できませんでした: ${String(error)}`)
529 })
530 .finally(() => {
531 current.isChecking = false
532 })
533 }
534
535 /**
536 * 受信サーバを起動します。起動できなければ理由の文を返します。
537 *
538 * @param preferredPort 使いたい port (引き継ぎで起動し直すとき)。塞がっていれば receiver.py が選び直す
539 */
540 async function startReceiver(
541 engine: Host,
542 python: readonly string[],
543 sheet: Sheet,
544 token: string,
545 preferredPort?: number,
546 ): Promise<ReceiverInfo | string> {
547 const { paths } = sheet
548 const argv = receiverArgv(python, { pluginRoot: engine.pluginRoot, html: paths.html, out: paths.answer }, token, preferredPort)
549 try {
550 return parseStartOutput((await engine.run(argv, { timeoutMs: 10000 })).stdout) ?? STRINGS.noPort
551 } catch (error) {
552 return `受信サーバを起動できませんでした (${String(error)})`
553 }
554 }
555
556 /**
557 * 待機を組みます: `pending` を置き、回答ファイルの監視を始め、記録を書きます。
558 * ブラウザとペインは開きません (呼ぶ側が決めます)。
559 */
560 async function armPending(
561 engine: Host,
562 sheet: Sheet,
563 receiver: ReceiverInfo,
564 options: { token: string; startedAtMs: number; isSyncWaiting: boolean },
565 ): Promise<Pending> {
566 const current: Pending = {
567 sheet,
568 receiver,
569 token: options.token,
570 url: urlOf(receiver.port, options.token),
571 linkUrl: linkUrlOf(receiver.port, options.token),
572 startedAtMs: options.startedAtMs,
573 timer: { cancel: () => undefined },
574 ticks: 0,
575 isChecking: false,
576 isSyncWaiting: options.isSyncWaiting,
577 dropReason: null,
578 isUrlShown: false,
579 }
580 current.timer = engine.every(WATCH_INTERVAL_MS, () => tick(engine, current))
581 pending = current
582 elapsedSeconds = Math.max(0, Math.floor(((await engine.now()) - options.startedAtMs) / 1000))
583 await saveRecord(engine, current)
584 return current
585 }
586
587 const failed = (reason: string, sheet: Sheet) =>
588 ({
589 result: JSON.stringify({
590 status: 'failed',
591 reason: `${reason}。${STRINGS.fallbackOf(sheet.paths.html)}`,
592 files: sheet.files,
593 }),
594 }) as const
595
596 const invalid = (errors: string[]) => ({ result: JSON.stringify({ status: 'invalid', errors }) }) as const
597
598 /**
599 * 書き終えた画面の HTML を受信サーバで配り、回答を待ちます (open_form と open_review で共通)。
600 * 前の待機は差し替えます。`waitSeconds` までは呼び出しの中で待ち、届けば `answered` を返します。
601 */
602 async function serveSheet(
603 engine: Host,
604 sheet: Sheet,
605 options: {
606 nowMs: number
607 isBrowserWanted: boolean
608 waitSeconds: number
609 signal: AbortSignal
610 /** 受信サーバが立った直後に呼ぶ (ライブ表示のタブを移す) */
611 onServed?: (url: string) => Promise<void>
612 },
613 ) {
614 const { paths } = sheet
615 // 同じ label を出し直すと前回の回答ファイルは消えるので、起動時に見つけた同じ label の未送の回答も捨てる
616 if (unsent?.sheet.label === sheet.label) {
617 unsent = null
618 }
619 const python = await pythonOf(engine)
620 if (!python) {
621 return failed(STRINGS.noPython, sheet)
622 }
623
624 // 同じ label の前回の回答を消す (残っていると古い回答を拾う)
625 await engine.run(cleanupArgv(python, engine.pluginRoot, { out: paths.answer }), { timeoutMs: 5000 }).catch(() => undefined)
626
627 const token = tokenOf(options.nowMs)
628 const receiver = await startReceiver(engine, python, sheet, token)
629 if (typeof receiver === 'string') {
630 return failed(receiver, sheet)
631 }
632
633 const current = await armPending(engine, sheet, receiver, {
634 token,
635 startedAtMs: options.nowMs,
636 isSyncWaiting: options.waitSeconds > 0,
637 })
638 const { url } = current
639
640 await options.onServed?.(url)
641 if (options.isBrowserWanted) {
642 openBrowser(engine, url)
643 }
644 void openPane(engine, false)
645
646 const identity = { documentId: sheet.documentId, revision: sheet.revision }
647
648 // 同期待ち: 上限までは tool.call の中で回答を待ち、Tool result で返す
649 const end = await waitForAnswer(
650 engine,
651 { read: sheet.replyOf, answerPath: paths.answer, port: receiver.port, token },
652 { waitSeconds: options.waitSeconds, signal: options.signal, isStillPending: () => pending === current },
653 )
654 current.isSyncWaiting = false
655
656 // 回答が読めても、その間に差し替えや取り消しがあれば届けない (差し替え先の記録やペインを触らない)
657 if (end.kind === 'answered' && pending === current) {
658 pending = null
659 current.timer.cancel()
660 await settle(engine, sheet, end.answer)
661 // 同期経路は Tool result で届くので、ここで数える
662 rememberSettled(sheet)
663 return {
664 result: JSON.stringify({
665 status: 'answered',
666 ...identity,
667 reply: end.answer,
668 files: { ...sheet.files, answer: paths.answer, md: paths.md },
669 }),
670 context: [sheet.answeredContext],
671 }
672 }
673
674 if (end.kind !== 'pending') {
675 return {
676 result: JSON.stringify({
677 status: 'cancelled',
678 ...identity,
679 reason:
680 current.dropReason === 'replaced'
681 ? STRINGS.replacedByAnother
682 : current.dropReason === 'released'
683 ? STRINGS.releasedToOther
684 : STRINGS.cancelledByPerson,
685 }),
686 }
687 }
688
689 // 回答は届いていない。以後は監視タイマーが prompt.submit で届ける
690 return {
691 result: JSON.stringify({
692 status: 'pending',
693 ...identity,
694 url,
695 files: sheet.files,
696 wait: { seconds: end.waitedSeconds, endedBy: end.endedBy },
697 }),
698 context: [sheet.pendingContext],
699 }
700 }
701
702 /**
703 * 記録から画面を組み直します。`doc-desk/<label>.json` が無い、検証を通らない、記録と版が違うときは null。
704 * 配る HTML が消えていれば書き直します。
705 */
706 async function sheetOfRecord(engine: Host, record: PendingRecord): Promise<Sheet | null> {
707 const paths = pathsOf(record.label)
708 if (!(await engine.exists(paths.form))) {
709 return null
710 }
711 let parsed: unknown
712 try {
713 parsed = JSON.parse(await engine.readFile(paths.form))
714 } catch {
715 return null
716 }
717
718 let sheet: Sheet
719 let renderSheet: () => Promise<string | null>
720 if (record.kind === 'form') {
721 const validation = validateForm(parsed)
722 if (!validation.ok) {
723 return null
724 }
725 const { form } = validation
726 sheet = sheetOfForm(form, paths)
727 renderSheet = async () => renderHtml({ form, date: dateOf(record.startedAtMs) })
728 } else {
729 const validation = validateReview(parsed)
730 if (!validation.ok) {
731 return null
732 }
733 const { review } = validation
734 sheet = sheetOfReview(review, paths, await engine.exists(paths.candidates))
735 renderSheet = async () => {
736 const html = await engine.readFile(paths.doc).catch(() => null)
737 return html === null ? null : renderReviewHtml({ review, html, date: dateOf(record.startedAtMs) })
738 }
739 }
740 if (sheet.documentId !== record.documentId || sheet.revision !== record.revision) {
741 return null
742 }
743
744 if (!(await engine.exists(paths.html))) {
745 const html = await renderSheet()
746 if (html === null) {
747 return null
748 }
749 await engine.writeFile(paths.html, html)
750 }
751 return sheet
752 }
753
754 /**
755 * 受信サーバが生きていて、まだ回答を受けていなければ true。`GET /wait?timeout=0` で見ます。
756 */
757 async function isReceiverWaiting(engine: Host, record: PendingRecord): Promise<boolean> {
758 try {
759 const response = await engine.fetch(waitUrlOf(record.port, record.token, 0))
760 if (!response.ok) {
761 return false
762 }
763 return (JSON.parse(response.text) as { answered?: unknown }).answered === false
764 } catch {
765 return false
766 }
767 }
768
769 /**
770 * 起動時に見つけた回答を届けます。人がいる surface では勝手に turn を始めず、
771 * `/doc-desk-resume` を候補に出して待ちます (記録は送るまで残します)。
772 */
773 async function deliverOnStart(engine: Host, sheet: Sheet, reply: string, isHeadless: boolean) {
774 await writeReply(engine, sheet, reply)
775 if (isHeadless) {
776 engine.uiLog(STRINGS.receivedOf(sheet.paths.md))
777 // session.start は最初の prompt より前に待たれるので、turn の開始を待たない。
778 // 記録は投入が受け付けられてから消す (その前にプロセスが終わっても、次の起動でまた届ける)
779 void engine.submitPrompt({ text: reply }).then(
780 result => {
781 if (!isAccepted(result)) {
782 engine.uiLog(STRINGS.unsentFailedOf(sheet.kind, sheet.label))
783 return
784 }
785 rememberSettled(sheet)
786 return forgetRecord(engine)
787 },
788 () => engine.uiLog(STRINGS.unsentFailedOf(sheet.kind, sheet.label)),
789 )
790 return
791 }
792 unsent = { sheet, reply }
793 engine.uiLog(STRINGS.unsentOf(sheet.kind, sheet.label))
794 engine.toast(STRINGS.unsentOf(sheet.kind, sheet.label))
795 void engine.suggest({ text: `/${COMMAND_NAME}` }).catch(() => undefined)
796 }
797
798 /**
799 * 引き継ぎの候補を新しい順に試し、最初に使えるものをこのセッションの記録にします。
800 *
801 * 先にこのセッションのキーへ書いてから前のキーを消します (逆の順だと、その間に lease の切れた持ち主の heartbeat が
802 * 「引き継がれていない」と判断して書き直し、両方が記録を持ってしまう)。古い、または証跡の無い候補は消して次を試し、
803 * 使える候補が決まったら、それより古い候補は差し替わったものとして片付けます (後日また引き継がない)。
804 */
805 async function claimFirstUsable(
806 engine: Host,
807 candidates: readonly { key: string; record: PendingRecord }[],
808 ): Promise<{ record: PendingRecord; sheet: Sheet } | null> {
809 for (const [index, { key, record }] of candidates.entries()) {
810 if (isStale(record, await engine.now())) {
811 await engine.storeDelete(key).catch(() => undefined)
812 engine.uiLog(STRINGS.staleRecordOf(record.kind, record.label))
813 continue
814 }
815 const sheet = await sheetOfRecord(engine, record)
816 if (!sheet) {
817 await engine.storeDelete(key).catch(() => undefined)
818 continue
819 }
820 await claimRecord(engine, record)
821 if (key !== recordKeyOf(cwd, sessionId)) {
822 await engine.storeDelete(key).catch(() => undefined)
823 }
824 for (const older of candidates.slice(index + 1)) {
825 await engine.storeDelete(older.key).catch(() => undefined)
826 engine.uiLog(STRINGS.supersededOf(older.record.kind, older.record.label))
827 }
828 return { record, sheet }
829 }
830 return null
831 }
832
833 /**
834 * 前のセッションの待機を引き継ぎます (`session.start` から呼びます)。
835 */
836 async function carryOver(engine: Host, isHeadless: boolean) {
837 // このセッションがもう自分の待機を持っていれば、引き継ぎで上書きしない
838 if (pending || unsent || ownedRecord) {
839 return
840 }
841 const { candidates, leaseEndMs } = await recordToCarryOver(engine)
842 const found = await claimFirstUsable(engine, candidates)
843 if (!found) {
844 if (leaseEndMs !== null) {
845 // 別のセッションが持っている記録がある。そのセッションがクラッシュして session.end が来なかったときに備え、
846 // lease が切れる頃にもう一度見る (持ち主が生きていれば heartbeat で lease が延び、また待つ)
847 const waitMs = Math.max(1000, leaseEndMs - (await engine.now()) + 1000)
848 engine.after(waitMs, () => {
849 void carryOver(engine, isHeadless).catch((error: unknown) => {
850 engine.uiLog(`前回の質問票を引き継げませんでした: ${String(error)}`)
851 })
852 })
853 }
854 return
855 }
856 const { record, sheet } = found
857
858 const reply = await readReply(engine, sheet)
859 if (reply !== null) {
860 await deliverOnStart(engine, sheet, reply, isHeadless)
861 return
862 }
863
864 let receiver: ReceiverInfo = { port: record.port, pid: record.pid }
865 if (!(await isReceiverWaiting(engine, record))) {
866 // `/wait` が answered を返した直後に回答ファイルが置かれることもあるので、もう一度だけ見る
867 const late = await readReply(engine, sheet)
868 if (late !== null) {
869 await deliverOnStart(engine, sheet, late, isHeadless)
870 return
871 }
872 const python = await pythonOf(engine)
873 if (!python) {
874 engine.uiLog(`${STRINGS.restartFailedOf(sheet.kind, sheet.label)} (${STRINGS.noPython})`)
875 return
876 }
877 const restarted = await startReceiver(engine, python, sheet, record.token, record.port)
878 if (typeof restarted === 'string') {
879 engine.uiLog(`${STRINGS.restartFailedOf(sheet.kind, sheet.label)} (${restarted})`)
880 return
881 }
882 if (restarted.port !== record.port && (await isReceiverWaiting(engine, record))) {
883 // 同じ port を取れなかったのは、古い受信サーバが生きていたから (さっきの確認は一時的な失敗)。
884 // 起動し直した方 (pid が確かなもの) を止めて、古い方を使う。古い pid は他のプロセスに使い回されて
885 // いるかもしれないので、こちらからは止めない
886 await engine.run(stopArgv(python, engine.pluginRoot, restarted.pid), { timeoutMs: 5000 }).catch(() => undefined)
887 } else {
888 receiver = restarted
889 if (receiver.port !== record.port) {
890 engine.uiLog(STRINGS.portChangedOf(sheet.kind, sheet.label, urlOf(receiver.port, record.token)))
891 }
892 }
893 }
894
895 await armPending(engine, sheet, receiver, { token: record.token, startedAtMs: record.startedAtMs, isSyncWaiting: false })
896 engine.status(STRINGS.carriedOverOf(sheet.kind, sheet.label))
897 }
898
899 /** ライブ表示 (docs/doc-desk/live-view-design.md)。状態と振る舞いは hooks/live/controller.ts に閉じ込める */
900 const live = createLiveController({
901 host: () => host,
902 cwd: () => cwd,
903 isPending: () => pending !== null,
904 isPaneOpen: () => isPaneOpen,
905 pythonOf,
906 spellingsOf,
907 openBrowser,
908 openPane: engine => openPane(engine, false),
909 closePane,
910 tokenOf,
911 dateOf,
912 })
913
914 on('session.start', async ($, e, next) => {
915 cwd = e.cwd
916
917 const engine: Host = {
918 now: () => $.clock.now(),
919 after: (ms, fn) => $.clock.after(ms, fn),
920 every: (ms, fn) => $.clock.every(ms, fn),
921 run: (argv, init) => $.process.run(argv, init),
922 writeFile: (path, text) => $.fs.write(path, text),
923 readFile: path => $.fs.read(path),
924 exists: path => $.fs.exists(path),
925 stat: (path, options) => $.fs.stat(path, options),
926 fetch: (url, init) => $.http.fetch(url, init),
927 storeGet: key => $.store.get(key),
928 storeSet: (key, value) => $.store.set(key, value),
929 storeDelete: key => $.store.delete(key),
930 storeKeys: () => $.store.keys(),
931 openPane: pane => $.ui.open(pane),
932 closePane: pane => $.ui.close(pane),
933 invalidate: () => $.ui.invalidate('ui.render'),
934 uiLog: text => $.ui.log(text),
935 status: text => $.ui.status(text),
936 toast: text => $.ui.toast(text),
937 submitPrompt: input => $.prompt.submit(input),
938 suggest: input => $.prompt.suggest(input),
939 abortTurn: input => $.turn.abort(input),
940 fork: request => $.model.fork(request),
941 pluginRoot: $.plugin.root,
942 }
943 host = engine
944
945 for (const tool of [
946 { name: TOOL_NAME, description: STRINGS.toolDescription, inputSchema: TOOL_INPUT_SCHEMA },
947 { name: REVIEW_TOOL_NAME, description: STRINGS.reviewToolDescription, inputSchema: REVIEW_INPUT_SCHEMA },
948 { name: LIVE_TOOL_NAME, description: STRINGS.liveToolDescription, inputSchema: LIVE_INPUT_SCHEMA },
949 ]) {
950 try {
951 await $.tool.register(tool)
952 } catch (error) {
953 engine.uiLog(`ツール ${tool.name} を登録できませんでした: ${String(error)}`)
954 }
955 }
956
957 try {
958 await $.command.register({
959 name: COMMAND_NAME,
960 description: STRINGS.commandDescription,
961 })
962 } catch (error) {
963 engine.uiLog(`/${COMMAND_NAME} を登録できませんでした: ${String(error)}`)
964 }
965
966 void pythonOf(engine)
967
968 sessionId = await $.session.id().catch(() => '')
969 engine.every(HEARTBEAT_INTERVAL_MS, () => {
970 void heartbeat(engine).catch(() => undefined)
971 })
972
973 try {
974 await carryOver(engine, e.surface === null)
975 } catch (error) {
976 engine.uiLog(`前回の質問票を引き継げませんでした: ${String(error)}`)
977 }
978
979 return next(e)
980 })
981
982 on('tool.call', { tool: 'mcp__doc-desk__open_form' }, async ($, e, next) => {
983 const engine = host
984 if (!engine) {
985 return { result: JSON.stringify({ status: 'failed', reason: 'session.start がまだ実行されていません' }) }
986 }
987
988 const validation = validateForm(e.form)
989 if (!validation.ok) {
990 return invalid(validation.errors)
991 }
992 const { form } = validation
993
994 await live.close(engine)
995 await dropPending(engine, 'replaced')
996
997 const paths = pathsOf(form.label)
998 const nowMs = await engine.now()
999
1000 await engine.writeFile(paths.form, `${JSON.stringify(form, null, 2)}\n`)
1001 await engine.writeFile(paths.html, renderHtml({ form, date: dateOf(nowMs) }))
1002
1003 return serveSheet(engine, sheetOfForm(form, paths), {
1004 nowMs,
1005 isBrowserWanted: e.openBrowser !== false,
1006 waitSeconds: clampWaitSeconds(e.waitSeconds),
1007 signal: next.signal,
1008 })
1009 })
1010
1011 on('tool.call', { tool: 'mcp__doc-desk__open_review' }, async ($, e, next) => {
1012 const engine = host
1013 if (!engine) {
1014 return { result: JSON.stringify({ status: 'failed', reason: 'session.start がまだ実行されていません' }) }
1015 }
1016
1017 const validation = validateReview(e.review)
1018 if (!validation.ok) {
1019 return invalid(validation.errors)
1020 }
1021 const { review } = validation
1022 const paths = pathsOf(review.label)
1023
1024 if (!(await engine.exists(paths.doc))) {
1025 return invalid([`${paths.doc}: ${STRINGS.noDocument}`])
1026 }
1027 const html = await engine.readFile(paths.doc).catch(() => null)
1028 if (html === null) {
1029 return invalid([`${paths.doc}: ${STRINGS.unreadableDocument}`])
1030 }
1031 const errors = documentErrors(html)
1032 if (errors.length > 0) {
1033 return invalid(errors.map(message => `${paths.doc}: ${message}`))
1034 }
1035
1036 // Claude 自身の指摘の候補 (fork に 1 問だけ投げる)。失敗しても候補なしで進む
1037 let candidates: ReviewCandidate[] = []
1038 const isSelfReview = wantsSelfReview(options, e.selfReview)
1039 if (isSelfReview) {
1040 const blocks = numberedBlocks(html)
1041 const reply = await engine.fork({ prompt: candidatePrompt(blocks, review.title) }).catch(() => null)
1042 // fork を待つ間に人が中断したら (fork はその turn の中断で aborted を返す)、受信サーバもブラウザも出さずに終える
1043 if (next.signal.aborted || (reply !== null && !reply.isAnswered && reply.reason === 'aborted')) {
1044 return {
1045 result: JSON.stringify({
1046 status: 'cancelled',
1047 documentId: review.documentId,
1048 revision: review.revision,
1049 reason: STRINGS.abortedDuringSelfReview,
1050 }),
1051 }
1052 }
1053 if (reply?.isAnswered) {
1054 candidates = parseCandidates(reply.text, blocks.length)
1055 }
1056 await engine.writeFile(paths.candidates, `${JSON.stringify(candidates, null, 2)}\n`)
1057 } else if (await engine.exists(paths.candidates)) {
1058 // 前回の候補の証跡を残すと、今回の画面に出した候補と取り違えるので消す
1059 const python = await pythonOf(engine)
1060 if (python) {
1061 await engine.run(removeArgv(python, engine.pluginRoot, [paths.candidates]), { timeoutMs: 5000 }).catch(() => undefined)
1062 }
1063 }
1064
1065 // 同じ文書のライブ表示が開いていれば、まだ届けていないライブ指摘を候補にし、同じタブを指摘の画面へ移す。
1066 // 別の文書なら片付ける
1067 const handoff = await live.takeForReview(engine, review.documentId)
1068
1069 await dropPending(engine, 'replaced')
1070
1071 const nowMs = await engine.now()
1072
1073 await engine.writeFile(paths.form, `${JSON.stringify(review, null, 2)}\n`)
1074 await engine.writeFile(
1075 paths.html,
1076 renderReviewHtml({ review, html, date: dateOf(nowMs), candidates: [...candidates, ...(handoff?.candidates ?? [])] }),
1077 )
1078
1079 let isServed = false
1080 const result = await serveSheet(engine, sheetOfReview(review, paths, isSelfReview), {
1081 nowMs,
1082 // ライブ表示のタブが移るので、そのときだけブラウザは開き直さない
1083 isBrowserWanted: handoff ? e.openBrowser === true : e.openBrowser !== false,
1084 waitSeconds: clampWaitSeconds(e.waitSeconds),
1085 signal: next.signal,
1086 ...(handoff && {
1087 onServed: (url: string) => {
1088 isServed = true
1089 return handoff.serve(url)
1090 },
1091 }),
1092 })
1093 // 指摘の画面を出せなかった (受信サーバが起動しない)。ライブ表示の受信サーバを止める
1094 // (ライブ指摘は指摘の画面の HTML と .comments.json に残る)
1095 if (handoff && !isServed) {
1096 await handoff.abandon()
1097 }
1098 return result
1099 })
1100
1101 on('tool.call', { tool: 'mcp__doc-desk__open_live' }, async ($, e, next) => {
1102 const engine = host
1103 if (!engine) {
1104 return { result: JSON.stringify({ status: 'failed', reason: 'session.start がまだ実行されていません' }) }
1105 }
1106 if (!isLiveViewEnabled(options)) {
1107 return { result: JSON.stringify({ status: 'disabled' }) }
1108 }
1109 return live.open(engine, { live: e.live, openBrowser: e.openBrowser })
1110 })
1111
1112 on('command.run', { command: COMMAND_NAME }, async () => {
1113 const engine = host
1114 if (!engine) {
1115 return { text: STRINGS.nothingPending }
1116 }
1117
1118 // 起動時に見つけた回答が未送なら送る
1119 const waiting = unsent
1120 if (waiting) {
1121 unsent = null
1122 engine.uiLog(STRINGS.receivedOf(waiting.sheet.paths.md))
1123 // command.run の中の prompt.submit はエンジンが拒む (このコマンドが握る turn を待つことになる) ので、
1124 // タイマーでコマンドが終わった後に回す。記録は投入が受け付けられてから消し、失敗したら未送に戻す
1125 engine.after(0, () => {
1126 const restoreUnsent = () => {
1127 unsent = waiting
1128 engine.uiLog(STRINGS.unsentFailedOf(waiting.sheet.kind, waiting.sheet.label))
1129 }
1130 void engine.submitPrompt({ text: waiting.reply }).then(async result => {
1131 if (!isAccepted(result)) {
1132 restoreUnsent()
1133 return
1134 }
1135 rememberSettled(waiting.sheet)
1136 if (!pending) {
1137 await forgetRecord(engine)
1138 }
1139 }, restoreUnsent)
1140 })
1141 return { text: STRINGS.sentUnsentOf(waiting.sheet.kind, waiting.sheet.label) }
1142 }
1143
1144 const current = pending
1145 if (!current) {
1146 const writing = live.current()
1147 if (!writing) {
1148 return { text: STRINGS.nothingPending }
1149 }
1150 await openPane(engine, true)
1151 openBrowser(engine, writing.url)
1152 return { text: STRINGS.liveReopenedOf(writing.url) }
1153 }
1154 await openPane(engine, true)
1155 openBrowser(engine, current.url)
1156 return { text: STRINGS.reopenedOf(current.url) }
1157 })
1158
1159 on('ui.render', { component: 'Pane' }, ($, e, next) => {
1160 const engine = host
1161 const current = pending
1162 const writing = live.current()
1163 if (e.requestId !== PANE_ID || !engine || (!current && !writing)) {
1164 return next(e)
1165 }
1166
1167 const { Box, Text, Button, Link } = $.ui.resolve(e)
1168
1169 // 回答待ちの画面が無ければ、ライブ表示を描く
1170 if (!current && writing) {
1171 return paneView(
1172 { Box, Text, Button, Link },
1173 {
1174 heading: STRINGS.liveHeaderOf(writing.input.label),
1175 url: writing.url,
1176 linkUrl: writing.linkUrl,
1177 elapsedSeconds: live.elapsedSeconds(),
1178 prompt: STRINGS.liveInBrowser,
1179 stateLine: STRINGS.liveStateOf(writing.state || STRINGS.liveWaiting, live.elapsedSeconds()),
1180 },
1181 {
1182 openBrowser: () => openBrowser(engine, writing.url),
1183 cancel: () => {
1184 void live.close(engine).then(() => engine.uiLog(STRINGS.liveCancelled))
1185 },
1186 },
1187 )
1188 }
1189 if (!current) {
1190 return next(e)
1191 }
1192
1193 return paneView(
1194 { Box, Text, Button, Link },
1195 {
1196 heading: current.sheet.heading,
1197 url: current.url,
1198 linkUrl: current.linkUrl,
1199 elapsedSeconds,
1200 },hooks/form/answer.ts 31 lines1import type { AnswerV1, FormV1 } from './form-v1'
2
3const isRecord = (value: unknown): value is Record<string, unknown> =>
4 typeof value === 'object' && value !== null
5
6/**
7 * 回答ファイルの中身を、質問票に対する回答として読みます。
8 *
9 * JSON でない、`answers` が無い、`documentId` か `revision` が質問票と違う、のいずれかなら null
10 * (別の質問票の回答や、同じ label の古い回答を拾わないため)。
11 *
12 * @param text `doc-desk/<label>.answer.json` の中身
13 * @param form 待っている質問票
14 * @returns 質問票に対する回答。違えば null
15 */
16export function parseAnswer(text: string, form: FormV1): AnswerV1 | null {
17 let parsed: unknown
18 try {
19 parsed = JSON.parse(text)
20 } catch {
21 return null
22 }
23 if (!isRecord(parsed) || !isRecord(parsed.answers)) {
24 return null
25 }
26 if (parsed.documentId !== form.documentId || parsed.revision !== form.revision) {
27 return null
28 }
29 return parsed as unknown as AnswerV1
30}
31hooks/form/form-v1.ts 107 lines1/**
2 * 質問票 JSON スキーマ v1 (skills/doc-desk/references/form-spec-v1.md)。
3 *
4 * Claude が `open_form` ツールに渡す形です。検証は `validate.ts` が行い、
5 * 通ったものだけがこの型として扱われます。
6 */
7export type FormV1 = {
8 /** 常に 1 */
9 schemaVersion: 1
10 /** 文書の識別子。`^[A-Za-z0-9_-]{1,64}$` */
11 documentId: string
12 /** 同じ文書の何枚目の質問票か。1 以上の整数 */
13 revision: number
14 /** 証跡ファイルの名前に使う。`^[A-Za-z0-9_-]{1,64}$` */
15 label: string
16 /**
17 * これから書く、または更新する文書のパス (cwd 基準の相対か絶対)。1〜1024 文字。省略可。
18 * 書けば、回答が届くまで Mod がこのパスへの Write / Edit / NotebookEdit を止めます
19 */
20 source?: string
21 /** 主張のタイトル (内容の要約ではなく言い切り) */
22 title: string
23 /** 結論ボックスの本文。「結論:」で始まる 3 文以内 */
24 conclusion: string
25 /** 読者が知らない語だけ。省略可 */
26 glossary?: GlossaryEntry[]
27 /**
28 * 構成案。文書の見出しと各節の要旨を書いた HTML の断片 (使える要素は `outline.ts`)。
29 * 問いは `<span data-q="<問い ID>"></span>`、表は `<div data-table="<表 ID>"></div>` の印で置く
30 */
31 outline: string
32 /** テーマ (章)。1 つ以上 */
33 themes: Theme[]
34 /** 表。省略可 */
35 tables?: Table[]
36 /** 全体へのコメント欄のラベル。省略可 */
37 globalNote?: { label?: string }
38}
39
40export type GlossaryEntry = {
41 term: string
42 definition: string
43}
44
45export type Theme = {
46 id: string
47 name: string
48 questions: Question[]
49}
50
51export type Question = {
52 id: string
53 /** 問いの 1 文 */
54 title: string
55 /** 根拠。`file:line` か実行結果の引用。空は不可 */
56 cite: string
57 /** 選択肢。2 つ以上 */
58 options: Option[]
59 /** 補足入力欄の設定。省略可 */
60 note?: { placeholder?: string }
61}
62
63export type Option = {
64 id: string
65 label: string
66 /** 選ぶ理由 (利点)。空は不可 */
67 pros: string
68 /** 代償。空は不可 */
69 cons: string
70 /** 推奨案。1 問に高々 1 つ */
71 recommended?: boolean
72 /** この案を選んだときに、構成案の印に入る文。省略時は label */
73 preview?: string
74}
75
76export type Table = {
77 id: string
78 title: string
79 /** 列名。1〜6 */
80 columns: string[]
81 /** 行。0〜20 行。各行の長さは columns と同じ */
82 rows: string[][]
83 /** 編集できる列。columns と同じ長さ。省略時は全列 true */
84 editable?: boolean[]
85}
86
87/**
88 * ブラウザが POST する回答 JSON (references/reply-format-v1.md の「回答 JSON」)。
89 */
90export type AnswerV1 = {
91 schemaVersion: 1
92 documentId: string
93 revision: number
94 answers: Record<string, { choice: string | null; note: string }>
95 tables?: Record<string, string[][]>
96 globalNote?: string
97 submittedAt?: string
98}
99
100/**
101 * 質問票の全問を、テーマ順 → 問い順に平らに並べます。
102 * 回答固定形の `Qn.` の通し番号はこの並びで振ります。
103 */
104export function questionsOf(form: FormV1): Question[] {
105 return form.themes.flatMap(theme => theme.questions)
106}
107hooks/form/schema.ts 228 lines1/**
2 * `$.tool.register` の `inputSchema`。質問票 JSON スキーマ v1 (skills/doc-desk/references/form-spec-v1.md) を
3 * JSON Schema で書いたものです。細かい規則 (問いの数、ID の一意性など) は
4 * `validate.ts` が検査し、エラーを Claude に返します。
5 */
6const nonEmptyString = { type: 'string', minLength: 1 } as const
7
8export const FORM_SCHEMA = {
9 type: 'object',
10 description: '質問票 JSON スキーマ v1',
11 properties: {
12 schemaVersion: { type: 'integer', const: 1 },
13 documentId: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,64}$', description: '文書の識別子' },
14 revision: { type: 'integer', minimum: 1, description: '同じ文書の何枚目の質問票か' },
15 label: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,64}$', description: '証跡ファイル名 (doc-desk/<label>.json)' },
16 source: {
17 type: 'string',
18 minLength: 1,
19 maxLength: 1024,
20 description: 'これから書く、または更新する文書のパス。回答が届くまで Mod がこのパスへの書き込みを止める',
21 },
22 title: { ...nonEmptyString, description: '主張のタイトル (要約ではなく言い切り)' },
23 conclusion: { ...nonEmptyString, description: '「結論:」で始まる 3 文以内' },
24 glossary: {
25 type: 'array',
26 description: '読者が知らない語だけ 1 行ずつ',
27 items: {
28 type: 'object',
29 properties: { term: nonEmptyString, definition: nonEmptyString },
30 required: ['term', 'definition'],
31 },
32 },
33 outline: {
34 ...nonEmptyString,
35 description:
36 '構成案。文書の見出しと各節の要旨を書いた HTML の断片 (本文は書かない)。' +
37 '問いは <span data-q="問い ID"></span>、表は <div data-table="表 ID"></div> の印で、影響する箇所に置く',
38 },
39 themes: {
40 type: 'array',
41 minItems: 1,
42 description: 'テーマ (章)。全テーマの問いの合計は 2〜5 問',
43 items: {
44 type: 'object',
45 properties: {
46 id: nonEmptyString,
47 name: nonEmptyString,
48 questions: {
49 type: 'array',
50 items: {
51 type: 'object',
52 properties: {
53 id: nonEmptyString,
54 title: { ...nonEmptyString, description: '問いの 1 文' },
55 cite: { ...nonEmptyString, description: '根拠: file:line か実行結果の引用' },
56 options: {
57 type: 'array',
58 minItems: 2,
59 items: {
60 type: 'object',
61 properties: {
62 id: nonEmptyString,
63 label: nonEmptyString,
64 pros: { ...nonEmptyString, description: '利点 (選ぶ理由) 1 行' },
65 cons: { ...nonEmptyString, description: '代償 1 行' },
66 recommended: { type: 'boolean', description: '推奨案。1 問に 1 つまで' },
67 preview: { type: 'string', description: 'この案を選んだときに構成案の印に入る文。省略時は label' },
68 },
69 required: ['id', 'label', 'pros', 'cons'],
70 },
71 },
72 note: {
73 type: 'object',
74 properties: { placeholder: { type: 'string' } },
75 },
76 },
77 required: ['id', 'title', 'cite', 'options'],
78 },
79 },
80 },
81 required: ['id', 'name', 'questions'],
82 },
83 },
84 tables: {
85 type: 'array',
86 description: '人に埋めてもらう表',
87 items: {
88 type: 'object',
89 properties: {
90 id: nonEmptyString,
91 title: nonEmptyString,
92 columns: { type: 'array', minItems: 1, maxItems: 6, items: { type: 'string' } },
93 rows: {
94 type: 'array',
95 maxItems: 20,
96 items: { type: 'array', items: { type: 'string' } },
97 },
98 editable: { type: 'array', items: { type: 'boolean' }, description: '列ごとに編集可か。省略時は全列 true' },
99 },
100 required: ['id', 'title', 'columns', 'rows'],
101 },
102 },
103 globalNote: {
104 type: 'object',
105 properties: { label: { type: 'string' } },
106 },
107 },
108 required: ['schemaVersion', 'documentId', 'revision', 'label', 'title', 'conclusion', 'outline', 'themes'],
109} as const
110
111const OPEN_BROWSER = {
112 type: 'boolean',
113 description: 'false ならブラウザを起動せず URL だけ返す (既定 true)',
114} as const
115
116const WAIT_SECONDS = {
117 type: 'integer',
118 minimum: 0,
119 maximum: 1800,
120 description:
121 'ツール呼び出しの中で回答を待つ上限 (秒)。既定 300。0 なら待たずに pending を返す。' +
122 '上限までに回答が届けば status "answered" と reply (回答固定形) を返す',
123} as const
124
125/**
126 * ツール `open_form` の入力全体。
127 */
128export const TOOL_INPUT_SCHEMA = {
129 type: 'object',
130 properties: {
131 form: FORM_SCHEMA,
132 openBrowser: OPEN_BROWSER,
133 waitSeconds: WAIT_SECONDS,
134 },
135 required: ['form'],
136} as const
137
138/**
139 * 指摘の画面 (skills/doc-desk/references/review-mode.md) の `review`。
140 * 文書の本文は入力に載せず、Claude が `doc-desk/<label>.doc.html` に書き出したものを Mod が読みます。
141 */
142export const REVIEW_SCHEMA = {
143 type: 'object',
144 description: '指摘の画面 v1。文書の HTML は doc-desk/<label>.doc.html に先に書き出しておく',
145 properties: {
146 schemaVersion: { type: 'integer', const: 1 },
147 documentId: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,64}$', description: '文書の識別子 (質問票と同じでよい)' },
148 revision: { type: 'integer', minimum: 1, description: '同じ文書の何回目の指摘の画面か' },
149 label: {
150 type: 'string',
151 pattern: '^[A-Za-z0-9_-]{1,64}$',
152 description: '画面の名前。文書の HTML を doc-desk/<label>.doc.html から読み、証跡を doc-desk/<label>.* に書く',
153 },
154 title: { ...nonEmptyString, description: '画面の上に出す文書の題名' },
155 source: {
156 type: 'string',
157 minLength: 1,
158 maxLength: 1024,
159 description: '元の文書のパス。画面に出し、指摘が届くまで Mod がこのパスと doc-desk/<label>.doc.html への書き込みを止める',
160 },
161 part: {
162 type: 'object',
163 description: '長い文書を分けて出すときの、何回目か (index) と全部で何回か (total)',
164 properties: { index: { type: 'integer', minimum: 1 }, total: { type: 'integer', minimum: 2 } },
165 required: ['index', 'total'],
166 },
167 },
168 required: ['schemaVersion', 'documentId', 'revision', 'label', 'title'],
169} as const
170
171/**
172 * ライブ表示 (docs/doc-desk/live-view-design.md) の `live`。
173 */
174export const LIVE_SCHEMA = {
175 type: 'object',
176 description: 'ライブ表示 v1。source への Write と Edit をブラウザに流す',
177 properties: {
178 documentId: {
179 type: 'string',
180 pattern: '^[A-Za-z0-9_-]{1,64}$',
181 description: '文書の識別子。書き終えて呼ぶ open_review の documentId と揃えると、同じタブが指摘の画面へ移る',
182 },
183 label: {
184 type: 'string',
185 pattern: '^[A-Za-z0-9_-]{1,64}$',
186 description: '証跡の名前 (doc-desk/<label>.json と .html)。質問票や指摘の画面の label と別にする (例: <documentId>-live)',
187 },
188 source: {
189 type: 'string',
190 minLength: 1,
191 maxLength: 1024,
192 description: 'これから書く文書のパス (質問票の source と同じ値)。このパスへの Write と Edit だけを流す',
193 },
194 title: { ...nonEmptyString, description: '画面の上に出す文書の題名' },
195 },
196 required: ['documentId', 'label', 'source', 'title'],
197} as const
198
199/**
200 * ツール `open_live` の入力全体。
201 */
202export const LIVE_INPUT_SCHEMA = {
203 type: 'object',
204 properties: {
205 live: LIVE_SCHEMA,
206 openBrowser: OPEN_BROWSER,
207 },
208 required: ['live'],
209} as const
210
211/**
212 * ツール `open_review` の入力全体。
213 */
214export const REVIEW_INPUT_SCHEMA = {
215 type: 'object',
216 properties: {
217 review: REVIEW_SCHEMA,
218 openBrowser: OPEN_BROWSER,
219 waitSeconds: WAIT_SECONDS,
220 selfReview: {
221 type: 'boolean',
222 description:
223 '既定 true。画面を出す前に、あなた自身が文書の直しどころを最大 5 件見つけ、画面に「Claude の候補」として出します (人は採用か却下を選ぶだけ)。false で出しません',
224 },
225 },
226 required: ['review'],
227} as const
228hooks/form/validate.ts 267 lines1import type { FormV1 } from './form-v1'
2import { outlineErrors } from './outline'
3
4/**
5 * 検証の結果。通れば `form`、落ちれば `errors` (全部) を返します。
6 */
7export type Validation =
8 | { ok: true; form: FormV1 }
9 | { ok: false; errors: string[] }
10
11const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
12const MIN_QUESTIONS = 2
13const MAX_QUESTIONS = 5
14const MAX_COLUMNS = 6
15const MAX_ROWS = 20
16const MAX_PREVIEW_LENGTH = 200
17const MAX_SOURCE_LENGTH = 1024
18
19const isRecord = (value: unknown): value is Record<string, unknown> =>
20 typeof value === 'object' && value !== null && !Array.isArray(value)
21
22const isFilled = (value: unknown): value is string =>
23 typeof value === 'string' && value.trim() !== ''
24
25/**
26 * 質問票 JSON を検証します (規則は references/form-spec-v1.md)。
27 *
28 * エラーは最初の 1 つで止めず、見つかった分を全部返します。
29 * パスは `themes[0].questions[1].cite` の形です。
30 *
31 * @param input ツールに渡された `form`
32 * @returns 通れば `{ ok: true, form }`、落ちれば `{ ok: false, errors }`
33 */
34export function validateForm(input: unknown): Validation {
35 const errors: string[] = []
36 const fail = (path: string, message: string) => {
37 errors.push(`${path}: ${message}`)
38 }
39
40 if (!isRecord(input)) {
41 return { ok: false, errors: ['form: オブジェクトにしてください'] }
42 }
43
44 if (input.schemaVersion !== 1) {
45 fail('schemaVersion', '1 にしてください')
46 }
47
48 for (const key of ['documentId', 'label'] as const) {
49 const value = input[key]
50 if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
51 fail(key, '1〜64 文字の英数字と - と _ にしてください')
52 }
53 }
54
55 if (!Number.isInteger(input.revision) || (input.revision as number) < 1) {
56 fail('revision', '1 以上の整数にしてください')
57 }
58
59 if (input.source !== undefined && (!isFilled(input.source) || input.source.length > MAX_SOURCE_LENGTH)) {
60 fail('source', `省略するか、1〜${MAX_SOURCE_LENGTH} 文字の空でない文字列 (これから書く文書のパス) にしてください`)
61 }
62
63 for (const key of ['title', 'conclusion'] as const) {
64 if (!isFilled(input[key])) {
65 fail(key, '空でない文字列にしてください')
66 }
67 }
68
69 if (input.glossary !== undefined) {
70 if (!Array.isArray(input.glossary)) {
71 fail('glossary', '配列にしてください')
72 } else {
73 input.glossary.forEach((entry, index) => {
74 const at = `glossary[${index}]`
75 if (!isRecord(entry)) {
76 fail(at, 'オブジェクトにしてください')
77 return
78 }
79 if (!isFilled(entry.term)) fail(`${at}.term`, '空でない文字列にしてください')
80 if (!isFilled(entry.definition)) fail(`${at}.definition`, '空でない文字列にしてください')
81 })
82 }
83 }
84
85 const questionIds = new Set<string>()
86 const tableIds = new Set<string>()
87 const themeIds = new Set<string>()
88 let questionCount = 0
89
90 if (!Array.isArray(input.themes) || input.themes.length === 0) {
91 fail('themes', 'テーマを 1 つ以上にしてください')
92 } else {
93 input.themes.forEach((theme, themeIndex) => {
94 const themeAt = `themes[${themeIndex}]`
95 if (!isRecord(theme)) {
96 fail(themeAt, 'オブジェクトにしてください')
97 return
98 }
99 if (!isFilled(theme.id)) {
100 fail(`${themeAt}.id`, '空でない文字列にしてください')
101 } else if (themeIds.has(theme.id)) {
102 fail(`${themeAt}.id`, `テーマ ID "${theme.id}" が重複しています`)
103 } else {
104 themeIds.add(theme.id)
105 }
106 if (!isFilled(theme.name)) fail(`${themeAt}.name`, '空でない文字列にしてください')
107
108 if (!Array.isArray(theme.questions)) {
109 fail(`${themeAt}.questions`, '配列にしてください')
110 return
111 }
112
113 theme.questions.forEach((question, questionIndex) => {
114 questionCount += 1
115 const at = `${themeAt}.questions[${questionIndex}]`
116 if (!isRecord(question)) {
117 fail(at, 'オブジェクトにしてください')
118 return
119 }
120 if (!isFilled(question.id)) {
121 fail(`${at}.id`, '空でない文字列にしてください')
122 } else if (questionIds.has(question.id)) {
123 fail(`${at}.id`, `問い ID "${question.id}" が重複しています`)
124 } else {
125 questionIds.add(question.id)
126 }
127 if (!isFilled(question.title)) fail(`${at}.title`, '空でない文字列にしてください')
128 if (!isFilled(question.cite)) {
129 fail(`${at}.cite`, '根拠 (file:line か実行結果の引用) を書いてください。空は不可です')
130 }
131 if (question.note !== undefined) {
132 if (!isRecord(question.note)) {
133 fail(`${at}.note`, 'オブジェクトにしてください')
134 } else if (
135 question.note.placeholder !== undefined &&
136 typeof question.note.placeholder !== 'string'
137 ) {
138 fail(`${at}.note.placeholder`, '文字列にしてください')
139 }
140 }
141
142 if (!Array.isArray(question.options) || question.options.length < 2) {
143 fail(`${at}.options`, '選択肢を 2 つ以上にしてください')
144 return
145 }
146
147 const optionIds = new Set<string>()
148 let recommendedCount = 0
149 question.options.forEach((option, optionIndex) => {
150 const optionAt = `${at}.options[${optionIndex}]`
151 if (!isRecord(option)) {
152 fail(optionAt, 'オブジェクトにしてください')
153 return
154 }
155 if (!isFilled(option.id)) {
156 fail(`${optionAt}.id`, '空でない文字列にしてください')
157 } else if (optionIds.has(option.id)) {
158 fail(`${optionAt}.id`, `選択肢 ID "${option.id}" が重複しています`)
159 } else {
160 optionIds.add(option.id)
161 }
162 if (!isFilled(option.label)) fail(`${optionAt}.label`, '空でない文字列にしてください')
163 if (!isFilled(option.pros)) fail(`${optionAt}.pros`, '利点 (選ぶ理由) を 1 行書いてください')
164 if (!isFilled(option.cons)) fail(`${optionAt}.cons`, '代償を 1 行書いてください')
165 if (option.recommended !== undefined && typeof option.recommended !== 'boolean') {
166 fail(`${optionAt}.recommended`, 'true か false にしてください')
167 }
168 if (option.recommended === true) recommendedCount += 1
169 if (
170 option.preview !== undefined &&
171 (!isFilled(option.preview) || option.preview.length > MAX_PREVIEW_LENGTH)
172 ) {
173 fail(`${optionAt}.preview`, `省略するか、${MAX_PREVIEW_LENGTH} 文字以内の空でない文字列にしてください`)
174 }
175 })
176 if (recommendedCount > 1) {
177 fail(`${at}.options`, 'recommended: true は 1 問に 1 つまでにしてください')
178 }
179 })
180 })
181
182 if (questionCount < MIN_QUESTIONS) {
183 fail('themes', `問いは全テーマ合わせて ${MIN_QUESTIONS}〜${MAX_QUESTIONS} 問にしてください (今は ${questionCount} 問)。決定だけを問う形に整理してください`)
184 } else if (questionCount > MAX_QUESTIONS) {
185 fail('themes', `問いは全テーマ合わせて ${MIN_QUESTIONS}〜${MAX_QUESTIONS} 問にしてください (今は ${questionCount} 問)。圧縮してください`)
186 }
187 }
188
189 if (input.tables !== undefined) {
190 if (!Array.isArray(input.tables)) {
191 fail('tables', '配列にしてください')
192 } else {
193 input.tables.forEach((table, tableIndex) => {
194 const at = `tables[${tableIndex}]`
195 if (!isRecord(table)) {
196 fail(at, 'オブジェクトにしてください')
197 return
198 }
199 if (!isFilled(table.id)) {
200 fail(`${at}.id`, '空でない文字列にしてください')
201 } else if (tableIds.has(table.id)) {
202 fail(`${at}.id`, `表 ID "${table.id}" が重複しています`)
203 } else {
204 tableIds.add(table.id)
205 }
206 if (!isFilled(table.title)) fail(`${at}.title`, '空でない文字列にしてください')
207
208 const columns = table.columns
209 if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_COLUMNS) {
210 fail(`${at}.columns`, `列名を 1〜${MAX_COLUMNS} 個にしてください`)
211 return
212 }
213 columns.forEach((column, columnIndex) => {
214 if (typeof column !== 'string') fail(`${at}.columns[${columnIndex}]`, '文字列にしてください')
215 })
216
217 if (!Array.isArray(table.rows) || table.rows.length > MAX_ROWS) {
218 fail(`${at}.rows`, `行を 0〜${MAX_ROWS} 行にしてください`)
219 } else {
220 table.rows.forEach((row, rowIndex) => {
221 if (!Array.isArray(row) || row.length !== columns.length) {
222 fail(`${at}.rows[${rowIndex}]`, `列数 (${columns.length}) と同じ長さの配列にしてください`)
223 return
224 }
225 row.forEach((cell, cellIndex) => {
226 if (typeof cell !== 'string') fail(`${at}.rows[${rowIndex}][${cellIndex}]`, '文字列にしてください')
227 })
228 })
229 }
230
231 if (table.editable !== undefined) {
232 const editable = table.editable
233 if (
234 !Array.isArray(editable) ||
235 editable.length !== columns.length ||
236 !editable.every(flag => typeof flag === 'boolean')
237 ) {
238 fail(`${at}.editable`, `列数 (${columns.length}) と同じ長さの boolean 配列にしてください`)
239 }
240 }
241 })
242 }
243 }
244
245 if (input.globalNote !== undefined) {
246 if (!isRecord(input.globalNote)) {
247 fail('globalNote', 'オブジェクトにしてください')
248 } else if (input.globalNote.label !== undefined && typeof input.globalNote.label !== 'string') {
249 fail('globalNote.label', '文字列にしてください')
250 }
251 }
252
253 if (!isFilled(input.outline)) {
254 fail('outline', '構成案 (文書の見出しと各節の要旨を書いた HTML) を書いてください。空は不可です')
255 } else {
256 for (const message of outlineErrors(input.outline, [...questionIds], [...tableIds])) {
257 fail('outline', message)
258 }
259 }
260
261 if (errors.length > 0) {
262 return { ok: false, errors }
263 }
264
265 return { ok: true, form: input as unknown as FormV1 }
266}
267hooks/compact/record.ts 120 lines1import { STRINGS } from '../views/strings'
2
3/**
4 * 会話の圧縮 (`session.compact`) の結果に差し戻す「決定の記録」を組みます。
5 *
6 * 人が答えた回答固定形 (`doc-desk/<label>.md`) を原文のまま足し、要約で薄まらないようにします。
7 */
8
9/**
10 * 決定の記録の見出し。SKILL.md の手順 6 はこの見出しで記録を探します。
11 */
12export const RECORD_HEADING = '【doc-desk 決定の記録】'
13
14/**
15 * 決定の記録の message か (前の圧縮で足したものを、次の圧縮で二重に足さないために見る)。
16 */
17export const isDecisionRecord = (text: string): boolean => text.startsWith(RECORD_HEADING)
18
19/**
20 * 回答待ちの画面。圧縮で Tool result と `turn.complete` の URL が消えても、回答先を案内できるように載せます。
21 */
22export type WaitingSheet = {
23 kind: 'form' | 'review'
24 label: string
25 url: string
26}
27
28/**
29 * 差し戻す文の上限 (文字)。超えたら各回答を決定の部分だけにし、それでも超えれば新しいものから入れます。
30 */
31export const RECORD_MAX_CHARS = 20000
32
33/**
34 * このセッションで届いた回答 1 つ分。
35 */
36export type SettledReply = {
37 label: string
38 /** `doc-desk/<label>.md` */
39 mdPath: string
40 /** `.md` の中身 (回答固定形) */
41 text: string
42}
43
44/**
45 * 回答固定形の決定の部分。質問票は見出しと `Qn.` の行と `## 表` (人が埋めた表も決定)、指摘の画面は見出しと対象、
46 * `## 指摘` の節、`## 書き換え` の各項目の見出し行 (`前:` と `後:` の全文は外す) です。
47 * 全体へのコメントと締めの文は外します。
48 */
49export function decisionPartOf(text: string): string {
50 const kept: string[] = []
51 let section = ''
52 for (const line of text.replace(/\r\n/g, '\n').replace(/\s+$/, '').split('\n')) {
53 if (line === '---' || line.startsWith('全体へのコメント: ')) {
54 break
55 }
56 if (line.startsWith('## ')) {
57 section = line
58 if (line === '## 表' || line === '## 指摘' || line === '## 書き換え') {
59 kept.push(line)
60 }
61 continue
62 }
63 if (section === '## 指摘した段落') {
64 continue
65 }
66 if (section === '## 書き換え' && (line.startsWith('前: ') || line.startsWith('後: ') || line.startsWith(' '))) {
67 continue
68 }
69 kept.push(line)
70 }
71 return kept.join('\n')
72}
73
74/**
75 * 差し戻す文を組みます。届いた回答が無ければ null (圧縮に手を加えない)。
76 *
77 * 1. 全文 (古い順) が上限に収まればそのまま載せる。
78 * 2. 収まらなければ各回答を決定の部分 (`decisionPartOf`) にする。
79 * 3. それでも収まらなければ新しいものから入れ、入らなかった回答はパスだけ書く。
80 *
81 * @param replies このセッションで届いた回答 (古い順)
82 * @param waiting 回答待ちの画面。あれば「届くまで対象の文書を書かない」と回答先の URL を足す
83 * @param maxChars 上限 (テスト用)
84 */
85export function buildDecisionRecord(
86 replies: readonly SettledReply[],
87 waiting: WaitingSheet | null,
88 maxChars: number = RECORD_MAX_CHARS,
89): string | null {
90 if (replies.length === 0) {
91 return null
92 }
93 const tail =
94 waiting === null
95 ? []
96 : [STRINGS.waitingNoteOf(waiting.kind, waiting.label, waiting.url)]
97 const compose = (bodies: readonly string[]): string => [RECORD_HEADING, ...bodies, ...tail].join('\n\n')
98
99 const full = replies.map(reply => reply.text.replace(/\s+$/, ''))
100 if (compose(full).length <= maxChars) {
101 return compose(full)
102 }
103
104 const parts = replies.map(reply => `${decisionPartOf(reply.text)}\n(全文: ${reply.mdPath})`)
105 if (compose(parts).length <= maxChars) {
106 return compose(parts)
107 }
108
109 // 新しいものから入れる。入らなかったものはパスだけ
110 const chosen: (string | null)[] = replies.map(() => null)
111 const pathOnly = (index: number) => `(入りきらなかった回答: ${replies[index]!.label} → ${replies[index]!.mdPath})`
112 for (let index = replies.length - 1; index >= 0; index -= 1) {
113 const trial = chosen.map((body, at) => (at === index ? parts[index]! : (body ?? pathOnly(at))))
114 if (compose(trial).length <= maxChars) {
115 chosen[index] = parts[index]!
116 }
117 }
118 return compose(chosen.map((body, at) => body ?? pathOnly(at)))
119}
120hooks/guard/paths.ts 64 lines1/**
2 * 回答待ちの間に書き込みを止めるパスの正規化と照合 (`tool.check` のフックが使います)。
3 *
4 * モジュールには Node が無いので、パスの処理は文字列で行います。
5 */
6
7const DRIVE = /^[A-Za-z]:\//
8
9/**
10 * Windows のパスか (ドライブ名で始まるか、区切りが `\`)。Windows では大文字小文字を区別しません。
11 */
12export const isWindowsPath = (path: string): boolean => DRIVE.test(path.replace(/\\/g, '/')) || path.includes('\\')
13
14/**
15 * パスを比べられる形にします: `\` を `/` に、cwd 基準の絶対パスに、`.` と `..` を解決し、
16 * 末尾の `/` を外します。Windows (cwd かパスが Windows の形) では小文字にします。
17 *
18 * @param path `Write` などの入力の `file_path`、質問票の `source` など
19 * @param cwd セッションの cwd (絶対)
20 */
21export function normalizePath(path: string, cwd: string): string {
22 const slashed = path.replace(/\\/g, '/')
23 const base = cwd.replace(/\\/g, '/')
24 const isAbsolute = slashed.startsWith('/') || DRIVE.test(slashed)
25 const joined = isAbsolute ? slashed : `${base.replace(/\/+$/, '')}/${slashed}`
26
27 const drive = DRIVE.exec(joined)?.[0].slice(0, 2) ?? ''
28 const segments: string[] = []
29 for (const segment of joined.slice(drive.length).split('/')) {
30 if (segment === '' || segment === '.') {
31 continue
32 }
33 if (segment === '..') {
34 segments.pop()
35 continue
36 }
37 segments.push(segment)
38 }
39 const normalized = `${drive}/${segments.join('/')}`
40 return isWindowsPath(cwd) || isWindowsPath(path) ? normalized.toLowerCase() : normalized
41}
42
43/**
44 * ツールの入力から書き込み先のパスを取り出します (`Write` と `Edit` は `file_path`、`NotebookEdit` は `notebook_path`)。
45 */
46export function writeTargetOf(input: unknown): string | null {
47 if (typeof input !== 'object' || input === null) {
48 return null
49 }
50 const { file_path: filePath, notebook_path: notebookPath } = input as { file_path?: unknown; notebook_path?: unknown }
51 if (typeof filePath === 'string' && filePath !== '') {
52 return filePath
53 }
54 if (typeof notebookPath === 'string' && notebookPath !== '') {
55 return notebookPath
56 }
57 return null
58}
59
60/**
61 * 2 つのパスの綴りの集まり (正規化した綴りと、あれば realPath) が 1 つでも重なるか。
62 */
63export const isSamePath = (a: readonly string[], b: readonly string[]): boolean => a.some(path => b.includes(path))
64hooks/host/index.ts 76 lines1import type {
2 FsStat,
3 FsStatOptions,
4 HttpInit,
5 HttpResponse,
6 ModelForkRequest,
7 ModelForkResult,
8 PaneCloseArgs,
9 PaneOpenArgs,
10 ProcessRunInit,
11 ProcessRunResult,
12 PromptSuggestArgs,
13 PromptSuggestResult,
14 PromptSubmitArgs,
15 PromptSubmitResult,
16 TimerCall,
17} from 'claude-code'
18
19/**
20 * `session.start` の中で `$` を束ねた関数群。
21 *
22 * `$` を変数に入れたり引数に渡したりすると `claude plugin validate` が拒否するため、
23 * 各メンバーは `session.start` の中で `(...) => $.名詞.イベント(...)` の閉包として作り、
24 * 後続のフック、タイマー、ボタンの押下からはこの Host を通して呼びます。
25 */
26export type Host = {
27 /** `$.clock.now` */
28 now: () => Promise<number>
29 /** `$.clock.after` (いまのディスパッチが終わってから動かしたいとき) */
30 after: TimerCall
31 /** `$.clock.every` */
32 every: TimerCall
33 /** `$.process.run` */
34 run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
35 /** `$.fs.write` (親ディレクトリも作られます) */
36 writeFile: (path: string, text: string) => Promise<void>
37 /** `$.fs.read` (テキスト) */
38 readFile: (path: string) => Promise<string>
39 /** `$.fs.exists` */
40 exists: (path: string) => Promise<boolean>
41 /** `$.fs.stat` (`{ resolve: true }` で realPath も。回答待ちの書き込みの照合に使う) */
42 stat: (path: string, options?: FsStatOptions) => Promise<FsStat>
43 /** `$.http.fetch` (受信サーバの `/wait` のロングポーリング) */
44 fetch: (url: string, init?: HttpInit) => Promise<HttpResponse>
45 /** `$.store.get` (待機の記録。セッションをまたいで残る) */
46 storeGet: (key: string) => Promise<unknown>
47 /** `$.store.set` */
48 storeSet: (key: string, value: unknown) => Promise<void>
49 /** `$.store.delete` */
50 storeDelete: (key: string) => Promise<void>
51 /** `$.store.keys` (同じフォルダの記録を探す) */
52 storeKeys: () => Promise<string[]>
53 /** `$.ui.open` */
54 openPane: (pane: PaneOpenArgs) => Promise<unknown>
55 /** `$.ui.close` */
56 closePane: (pane: PaneCloseArgs) => Promise<void>
57 /** `$.ui.invalidate("ui.render")` */
58 invalidate: () => void
59 /** `$.ui.log` (トランスクリプトに 1 行) */
60 uiLog: (text: string) => void
61 /** `$.ui.status` (プロンプト下の固定行) */
62 status: (text: string | undefined) => void
63 /** `$.ui.toast` (プロンプト下に数秒だけ出る通知) */
64 toast: (text: string) => void
65 /** `$.prompt.submit` */
66 submitPrompt: (input: PromptSubmitArgs) => Promise<PromptSubmitResult>
67 /** `$.turn.abort` (`turn.start` で覚えた id の turn を止める。ライブ指摘の [今すぐ止めて直す]) */
68 abortTurn: (input: { turnId: string }) => Promise<void>
69 /** `$.prompt.suggest` (プロンプト欄の薄い候補。Tab で取る) */
70 suggest: (input: PromptSuggestArgs) => Promise<PromptSuggestResult>
71 /** `$.model.fork` (main の会話の後ろに 1 問だけ足して答えさせる。指摘の候補に使う) */
72 fork: (request: ModelForkRequest) => Promise<ModelForkResult>
73 /** `$.plugin.root` (plugin.json のあるディレクトリ、絶対パス) */
74 pluginRoot: string
75}
76hooks/live/controller.ts 710 lines1import type { Timer, ToolCallResult, TurnStepChunk } from 'claude-code'
2
3import { normalizePath, isSamePath, writeTargetOf } from '../guard/paths'
4import type { Host } from '../host'
5import { EVIDENCE_DIR } from '../names'
6import {
7 documentUrlOf,
8 finishUrlOf,
9 linkUrlOf,
10 liveReceiverArgv,
11 parseStartOutput,
12 stopArgv,
13 urlOf,
14 waitUrlOf,
15 type ReceiverInfo,
16} from '../receiver'
17import { liveCandidatesOf, type ReviewCandidate } from '../review/candidates'
18import { renderLiveHtml } from '../sheet/render-live'
19import { STRINGS } from '../views/strings'
20import { afterContextOf, stopPromptOf, withRecords, type LiveCommentRecord, type LiveDelivery } from './comments'
21import { feedJson, initialJsonStream, type JsonStreamState } from './json-stream'
22import { parseLiveReply, validateLive, type LiveComment, type LivePhase, type LiveServerReply, type LiveV1 } from './live-v1'
23
24/**
25 * 貯めた文をまとめて送る文字数 (改行が来たら、最後の改行までを送る)。
26 */
27const LIVE_FLUSH_CHARS = 200
28
29/**
30 * ペインの経過秒数を進める間隔 (ms)。
31 */
32const LIVE_TICK_MS = 5000
33
34/**
35 * 受信サーバに生きていることを知らせる間隔 (タイマーの回数)。5 秒 × 12 = 60 秒。
36 * 受信サーバは Mod からの接触が 10 分途絶えると自分で終わるので、それより十分短くします。
37 */
38const KEEPALIVE_EVERY_TICKS = 12
39
40/**
41 * keepalive がこの回数続けて失敗したら、受信サーバは死んだとみなしてライブ表示を閉じます (設計書 5.5)。
42 */
43const KEEPALIVE_MAX_FAILURES = 3
44
45/**
46 * Mod が受信サーバへ送る文書の上限 (文字)。受信サーバと画面の上限 (receiver.py の LIVE_MAX_TEXT) と同じで、
47 * 超えた分は先頭から落とします (受信サーバの本文の上限 4 MiB を超えて接続ごと断られないように)。
48 */
49const LIVE_MAX_TEXT = 100000
50
51/**
52 * 開いているライブ表示 1 つ分の状態 (docs/doc-desk/live-view-design.md の 5.1)。`pending` とは別に持ちます。
53 */
54export type Live = {
55 input: LiveV1
56 paths: { input: string; html: string; comments: string }
57 /** `source` の綴り (正規化したものと、開いた時点でファイルがあれば realPath) */
58 spellings: string[]
59 receiver: ReceiverInfo
60 url: string
61 linkUrl: string
62 documentUrl: string
63 finishUrl: string
64 keepaliveUrl: string
65 startedAtMs: number
66 /** replace と append の通し番号 (status は番号を進めない) */
67 seq: number
68 /** 画面とペインの状態の文 */
69 state: string
70 /** 受信サーバから受け取り、まだ Claude に届けていない指摘 */
71 comments: LiveComment[]
72 /** 証跡 `doc-desk/<label>.comments.json` の中身 */
73 records: LiveCommentRecord[]
74 /** [今すぐ止めて直す] で turn を止めている間 true */
75 isStopping: boolean
76 /** [今すぐ止めて直す] で止めた turn の id (その turn の `turn.complete` で状態を上書きしない) */
77 stoppedTurnId: string | null
78 /** ペインの経過秒数と keepalive のタイマー */
79 timer: Timer
80 ticks: number
81 /** keepalive が続けて失敗した回数 */
82 keepaliveFailures: number
83}
84
85/**
86 * `turn.step` の中で読んでいる、Write か Edit の呼び出し 1 つ分。
87 */
88type LiveWriting = {
89 tool: 'Write' | 'Edit'
90 decoder: JsonStreamState
91 /** `file_path` が `source` か。まだ分からなければ null */
92 isTarget: boolean | null
93 /** まだ送っていない文 */
94 buffer: string
95}
96
97/**
98 * `turn.step` 1 回分の観察者。チャンクを見て文を流し、stream が終わったら残りを送ります。
99 */
100export type StepObserver = {
101 chunk: (chunk: TurnStepChunk) => void
102 end: () => void
103}
104
105/**
106 * 同じ文書の `open_review` へ引き渡すもの。
107 */
108export type LiveHandoff = {
109 /** 指摘の画面に埋める候補 (まだ届けていないライブ指摘) */
110 candidates: ReviewCandidate[]
111 /** 指摘の画面の受信サーバが立った後に呼ぶ (タブを移す) */
112 serve: (url: string) => Promise<void>
113 /** 指摘の画面を出せなかったときに呼ぶ (ライブ表示の受信サーバを閉じる) */
114 abandon: () => Promise<void>
115}
116
117/**
118 * ライブ表示が `register.ts` から借りるもの。`$` は渡さず、`session.start` で束ねた Host を通して呼びます。
119 */
120export type LiveDeps = {
121 host: () => Host | null
122 cwd: () => string
123 /** 回答待ちの画面があるか */
124 isPending: () => boolean
125 isPaneOpen: () => boolean
126 pythonOf: (engine: Host) => Promise<readonly string[] | null>
127 spellingsOf: (engine: Host, path: string) => Promise<string[]>
128 openBrowser: (engine: Host, url: string) => void
129 openPane: (engine: Host) => Promise<void>
130 closePane: (engine: Host) => Promise<void>
131 tokenOf: (nowMs: number) => string
132 dateOf: (ms: number) => string
133}
134
135/**
136 * ライブ表示を開くか。plugin の設定 (`userConfig.liveView`) が false なら開きません。
137 *
138 * @param options `register(on, options)` の options
139 */
140export const isLiveViewEnabled = (options: { liveView?: unknown }): boolean => options.liveView !== false
141
142const failedLive = (reason: string) => ({
143 result: JSON.stringify({ status: 'failed', reason: `${reason}。${STRINGS.liveFallback}` }),
144})
145
146const invalid = (errors: string[]) => ({ result: JSON.stringify({ status: 'invalid', errors }) })
147
148const JSON_HEADERS = { 'Content-Type': 'application/json' }
149
150/**
151 * ライブ表示 (docs/doc-desk/live-view-design.md) の状態と振る舞いをまとめた閉包を作ります。
152 * `register.ts` はフックからこの関数群を呼ぶだけにします。
153 */
154export function createLiveController(deps: LiveDeps) {
155 let live: Live | null = null
156 let elapsedSeconds = 0
157 /** 動いている main の turn の id (`turn.start` で覚え、`turn.complete` で消す)。[今すぐ止めて直す] が止める */
158 let mainTurnId: string | null = null
159
160 /**
161 * 受信サーバへ `POST /document` を送り、応答の指摘と「止めて」の印を受けます。
162 * replace と append は通し番号を進め (受信サーバが順に並べ直す)、status は今の番号のまま送ります。
163 * 失敗は無視します (次の replace で追いつき、指摘は次の POST で届きます)。
164 *
165 * @param canStop 応答の「止めて」で turn を止めてよいか。`tool.call` の中からは false
166 * (その中では `$.prompt.submit` が拒まれ、turn だけ止まって指摘が届かないため)
167 */
168 async function post(
169 engine: Host,
170 current: Live,
171 body: { kind: 'replace' | 'append' | 'status'; text?: string; phase?: LivePhase; delivered?: string[]; stopped?: string[] },
172 canStop = true,
173 ): Promise<void> {
174 if (body.kind !== 'status') {
175 current.seq += 1
176 } else if (body.text !== undefined && body.text !== current.state) {
177 current.state = body.text
178 if (deps.isPaneOpen() && !deps.isPending()) {
179 engine.invalidate()
180 }
181 }
182 let reply: LiveServerReply
183 try {
184 const response = await engine.fetch(current.documentUrl, {
185 method: 'POST',
186 headers: JSON_HEADERS,
187 body: JSON.stringify({ ...body, seq: current.seq }),
188 })
189 if (!response.ok) {
190 return
191 }
192 reply = parseLiveReply(response.text)
193 } catch {
194 return
195 }
196 receive(engine, current, reply, canStop)
197 }
198
199 /**
200 * 受信サーバから来た指摘を貯め、「止めて」の印があれば (止めてよい所からなら) turn を止めます。
201 * 止める処理は `$.clock.after(0)` で今のディスパッチの外へ出します。
202 */
203 function receive(engine: Host, current: Live, reply: LiveServerReply, canStop: boolean) {
204 if (live !== current) {
205 return
206 }
207 const known = new Set([...current.comments, ...current.records].map(comment => comment.id))
208 current.comments = [...current.comments, ...reply.comments.filter(comment => !known.has(comment.id))]
209 if (reply.stop && canStop) {
210 engine.after(0, () => {
211 void stopForComments(engine, current)
212 })
213 }
214 }
215
216 /**
217 * 指摘の扱いを証跡 `doc-desk/<label>.comments.json` に書きます。
218 */
219 async function record(engine: Host, current: Live, comments: readonly LiveComment[], how: LiveDelivery) {
220 if (comments.length === 0) {
221 return
222 }
223 current.records = withRecords(current.records, comments, how, await engine.now())
224 await engine.writeFile(current.paths.comments, `${JSON.stringify(current.records, null, 2)}\n`).catch(() => undefined)
225 }
226
227 const asAfter = (comments: readonly LiveComment[]): LiveComment[] =>
228 comments.map(comment => ({ ...comment, mode: 'after' as const }))
229
230 /**
231 * [今すぐ止めて直す]: `turn.start` で覚えた turn を `$.turn.abort` で止め、まだ届けていない指摘を
232 * 新しい user turn として投入します。
233 *
234 * 止める指摘 (`now`) がもう無い (Write の後の `context` で届いた) なら止めません。止めている最中に来た指摘は、
235 * 次の Write か Edit の後に届けます。止める turn が分からなければ [書き終わったら直す] と同じ扱いにします。
236 */
237 async function stopForComments(engine: Host, current: Live) {
238 if (live !== current || current.isStopping || !current.comments.some(comment => comment.mode === 'now')) {
239 return
240 }
241 const turnId = mainTurnId
242 if (turnId === null) {
243 current.comments = asAfter(current.comments)
244 engine.uiLog(STRINGS.liveNoTurnToStop)
245 return
246 }
247 current.isStopping = true
248 current.stoppedTurnId = turnId
249 // 途中で何が失敗しても isStopping を戻す (戻らないと以後の [今すぐ止めて直す] が効かなくなる)
250 try {
251 try {
252 await engine.abortTurn({ turnId })
253 } catch {
254 current.stoppedTurnId = null
255 current.comments = asAfter(current.comments)
256 engine.uiLog(STRINGS.liveNoTurnToStop)
257 return
258 }
259 const comments = current.comments
260 current.comments = []
261 let isSubmitted = false
262 try {
263 await record(engine, current, comments, 'prompt')
264 void post(engine, current, {
265 kind: 'status',
266 text: STRINGS.liveStopped,
267 phase: 'stopped',
268 stopped: comments.map(comment => comment.id),
269 })
270 // $.prompt.submit は session が idle になってから turn を始める (止めた turn の後片付けを待つ)
271 const submitted = await engine.submitPrompt({ text: stopPromptOf(current.input.source, comments) })
272 isSubmitted = submitted.drop === undefined
273 } catch {
274 isSubmitted = false
275 }
276 if (!isSubmitted) {
277 // 届かなかった指摘は、次の Write の後か指摘の画面で届ける
278 if (live === current) {
279 current.comments = [...asAfter(comments), ...current.comments]
280 }
281 engine.uiLog(STRINGS.liveStopSubmitFailed)
282 }
283 } finally {
284 current.isStopping = false
285 }
286 }
287
288 async function stopReceiver(engine: Host, current: Live) {
289 const python = await deps.pythonOf(engine)
290 if (python) {
291 await engine.run(stopArgv(python, engine.pluginRoot, current.receiver.pid), { timeoutMs: 5000 }).catch(() => undefined)
292 }
293 }
294
295 /**
296 * 受信サーバを閉じます。まず `POST /finish { close: true }` で画面に `closed` を流させて自分で終わらせ
297 * (画面が再接続を試み続けないように)、届かなければ pid で止めます。
298 */
299 async function closeReceiver(engine: Host, current: Live) {
300 try {
301 const response = await engine.fetch(current.finishUrl, {
302 method: 'POST',
303 headers: JSON_HEADERS,
304 body: JSON.stringify({ close: true }),
305 })
306 if (response.ok) {
307 return
308 }
309 } catch {
310 // 下で pid で止める
311 }
312 await stopReceiver(engine, current)
313 }
314
315 /**
316 * 受信サーバには触らずにライブ表示を手放します (受信サーバが死んでいるとき。pid は使い回されているかもしれない)。
317 */
318 async function forget(engine: Host, current: Live) {
319 if (live !== current) {
320 return
321 }
322 live = null
323 current.timer.cancel()
324 await record(engine, current, current.comments, 'dropped')
325 if (!deps.isPending()) {
326 await deps.closePane(engine)
327 }
328 engine.uiLog(STRINGS.liveReceiverGone)
329 }
330
331 /**
332 * ライブ表示を片付けます: 受信サーバを止め、まだ届けていない指摘を証跡に「届けずに片付けた」と書きます。
333 * ペインは、回答待ちの画面が無ければ閉じます。
334 */
335 async function close(engine: Host) {
336 const current = live
337 if (!current) {
338 return
339 }
340 live = null
341 current.timer.cancel()
342 await record(engine, current, current.comments, 'dropped')
343 await closeReceiver(engine, current)
344 if (!deps.isPending()) {
345 await deps.closePane(engine)
346 }
347 }
348
349 /**
350 * `open_live` の本体。検証し、HTML を書き、`--live` の受信サーバを起動します。
351 */
352 async function open(engine: Host, input: { live: unknown; openBrowser?: boolean }) {
353 const validation = validateLive(input.live)
354 if (!validation.ok) {
355 return invalid(validation.errors)
356 }
357 const value = validation.live
358 // 回答が届く前に文書を書くことは止めているので、ライブ表示もその前には開かない
359 if (deps.isPending()) {
360 return invalid([STRINGS.livePendingExists])
361 }
362
363 await close(engine)
364
365 const base = `${deps.cwd()}/${EVIDENCE_DIR}/${value.label}`
366 const paths = { input: `${base}.json`, html: `${base}.html`, comments: `${base}.comments.json` }
367 const nowMs = await engine.now()
368
369 // HTML を書き直すと、同じ label の前回の受信サーバ (前のセッションの残り) は自分で終わる。
370 // 証跡の指摘も空から始める (前回の同じ label の指摘と混ぜない)
371 await engine.writeFile(paths.input, `${JSON.stringify(value, null, 2)}\n`)
372 await engine.writeFile(paths.html, renderLiveHtml({ live: value, date: deps.dateOf(nowMs) }))
373 await engine.writeFile(paths.comments, '[]\n')
374
375 const python = await deps.pythonOf(engine)
376 if (!python) {
377 return failedLive(STRINGS.noPython)
378 }
379 const token = deps.tokenOf(nowMs)
380 let receiver: ReceiverInfo | null
381 try {
382 const argv = liveReceiverArgv(python, { pluginRoot: engine.pluginRoot, html: paths.html }, token)
383 receiver = parseStartOutput((await engine.run(argv, { timeoutMs: 10000 })).stdout)
384 } catch (error) {
385 return failedLive(`受信サーバを起動できませんでした (${String(error)})`)
386 }
387 if (!receiver) {
388 return failedLive(STRINGS.noPort)
389 }
390
391 const current: Live = {
392 input: value,
393 paths,
394 spellings: await deps.spellingsOf(engine, value.source),
395 receiver,
396 url: urlOf(receiver.port, token),
397 linkUrl: linkUrlOf(receiver.port, token),
398 documentUrl: documentUrlOf(receiver.port, token),
399 finishUrl: finishUrlOf(receiver.port, token),
400 keepaliveUrl: waitUrlOf(receiver.port, token, 0),
401 startedAtMs: nowMs,
402 seq: 0,
403 state: '',
404 comments: [],
405 records: [],
406 isStopping: false,
407 stoppedTurnId: null,
408 timer: { cancel: () => undefined },
409 ticks: 0,
410 keepaliveFailures: 0,
411 }
412 live = current
413 elapsedSeconds = 0
414 current.timer = engine.every(LIVE_TICK_MS, () => tick(engine, current))
415 void post(engine, current, { kind: 'status', text: STRINGS.liveWaiting, phase: 'waiting' })
416
417 if (input.openBrowser !== false) {
418 deps.openBrowser(engine, current.url)
419 }
420 void deps.openPane(engine)
421
422 return {
423 result: JSON.stringify({
424 status: 'opened',
425 documentId: value.documentId,
426 url: current.url,
427 files: { live: paths.input, html: paths.html },
428 }),
429 context: [STRINGS.liveOpenedContext],
430 }
431 }
432
433 /**
434 * タイマーの 1 回分: ペインの経過秒数を進め、60 秒ごとに受信サーバへ `GET /wait` を投げて生きていることを知らせます
435 * (人が書き終わった文書を 10 分以上読んでいても、セッションが生きている間は受信サーバが終わらないように)。
436 * 3 回続けて届かなければ、受信サーバは死んだとみなしてライブ表示を手放します (`/doc-desk-resume` が死んだ URL を開かないように)。
437 */
438 function tick(engine: Host, current: Live) {
439 if (live !== current) {
440 return
441 }
442 current.ticks += 1
443 if (current.ticks % KEEPALIVE_EVERY_TICKS === 0) {
444 void engine
445 .fetch(current.keepaliveUrl)
446 .then(
447 response => response.ok,
448 () => false,
449 )
450 .then(isAlive => {
451 current.keepaliveFailures = isAlive ? 0 : current.keepaliveFailures + 1
452 if (current.keepaliveFailures >= KEEPALIVE_MAX_FAILURES) {
453 return forget(engine, current)
454 }
455 return undefined
456 })
457 }
458 void engine.now().then(now => {
459 elapsedSeconds = Math.max(0, Math.floor((now - current.startedAtMs) / 1000))
460 if (live === current && deps.isPaneOpen() && !deps.isPending()) {
461 engine.invalidate()
462 }
463 })
464 }
465
466 /**
467 * 同じ文書の `open_review` へ渡すため、ライブ表示を切り離します (1 段目の `POST /finish {}`)。
468 * 受信サーバはこの後の指摘を受け付けないので、まだ届けていない指摘 (受信サーバがまだ Mod に渡していないものを含む) は
469 * すべて指摘の画面の候補になります。別の文書のライブ表示なら片付けて null を返します。
470 */
471 async function takeForReview(engine: Host, documentId: string): Promise<LiveHandoff | null> {
472 const current = live
473 if (!current) {
474 return null
475 }
476 if (current.input.documentId !== documentId) {
477 await close(engine)
478 return null
479 }
480 live = null
481 current.timer.cancel()
482 let untaken: LiveComment[] = []
483 try {
484 const response = await engine.fetch(current.finishUrl, { method: 'POST', headers: JSON_HEADERS, body: '{}' })
485 if (response.ok) {
486 untaken = parseLiveReply(response.text).comments
487 }
488 } catch {
489 // 受け取れなかった指摘は受信サーバの画面に残るだけ
490 }
491 const known = new Set(current.comments.map(comment => comment.id))
492 const comments = [...current.comments, ...untaken.filter(comment => !known.has(comment.id))]
493 current.comments = []
494 await record(engine, current, comments, 'review')
495 return {
496 candidates: liveCandidatesOf(comments),
497 serve: url => handOff(engine, current, url),
498 abandon: () => closeReceiver(engine, current),
499 }
500 }
501
502 /**
503 * 指摘の画面の受信サーバが立った後、ライブ表示のタブをそちらへ移します (2 段目の `POST /finish { url }`)。
504 * 送れなければライブ表示の受信サーバを止め、指摘の画面をブラウザで開きます。
505 */
506 async function handOff(engine: Host, current: Live, url: string) {
507 try {
508 const response = await engine.fetch(current.finishUrl, {
509 method: 'POST',
510 headers: JSON_HEADERS,
511 body: JSON.stringify({ url }),
512 })
513 if (response.ok) {
514 return
515 }
516 } catch {
517 // 下で閉じて開き直す
518 }
519 await closeReceiver(engine, current)
520 deps.openBrowser(engine, url)
521 }
522
523 /**
524 * `turn.step` 1 回分の観察者を返します。ライブ表示が開いていない、または subagent の step なら null (素通し)。
525 */
526 function observeStep(agentId: string | undefined): StepObserver | null {
527 const engine = deps.host()
528 const current = live
529 if (!engine || !current || agentId !== undefined) {
530 return null
531 }
532 const writings = new Map<number, LiveWriting>()
533 const flush = () => {
534 for (const writing of writings.values()) {
535 if (writing.buffer !== '') {
536 const text = writing.buffer
537 writing.buffer = ''
538 void post(engine, current, { kind: 'append', text })
539 }
540 }
541 }
542 return {
543 chunk: chunk => {
544 if (live === current) {
545 observeChunk(engine, current, writings, chunk, flush)
546 }
547 },
548 end: () => {
549 if (live === current) {
550 flush()
551 }
552 },
553 }
554 }
555
556 /**
557 * `turn.step` のチャンク 1 つを見て、`source` への Write の文を流します。チャンクは変えません。
558 */
559 function observeChunk(
560 engine: Host,
561 current: Live,
562 writings: Map<number, LiveWriting>,
563 chunk: TurnStepChunk,
564 flush: () => void,
565 ) {
566 switch (chunk.kind) {
567 case 'tool':
568 if (chunk.name === 'Write' || chunk.name === 'Edit') {
569 writings.set(chunk.index, { tool: chunk.name, decoder: initialJsonStream(), isTarget: null, buffer: '' })
570 }
571 // 次のツール呼び出しが始まった: 文が流れていない間に付いた指摘を取りに行く
572 void post(engine, current, { kind: 'status' })
573 return
574 case 'input': {
575 const writing = writings.get(chunk.index)
576 if (!writing || writing.isTarget === false) {
577 return
578 }
579 const out = feedJson(writing.decoder, chunk.json)
580 writing.decoder = out.state
581 if (out.filePath !== null) {
582 writing.isTarget = current.spellings.includes(normalizePath(out.filePath, deps.cwd()))
583 if (!writing.isTarget) {
584 return
585 }
586 if (writing.tool === 'Write') {
587 void post(engine, current, { kind: 'status', text: STRINGS.liveWriting, phase: 'writing' })
588 void post(engine, current, { kind: 'replace', text: '' })
589 } else {
590 void post(engine, current, { kind: 'status', text: STRINGS.liveFixing, phase: 'fixing' })
591 }
592 }
593 if (writing.tool !== 'Write' || writing.isTarget !== true || out.content === '') {
594 return
595 }
596 writing.buffer += out.content
597 // 改行が来たら最後の改行までを送り (行の途中は次に回す)、改行が無いまま長くなったら全部送る
598 const end = writing.buffer.lastIndexOf('\n') + 1
599 const size = end > 0 ? end : writing.buffer.length >= LIVE_FLUSH_CHARS ? writing.buffer.length : 0
600 if (size > 0) {
601 const text = writing.buffer.slice(0, size)
602 writing.buffer = writing.buffer.slice(size)
603 void post(engine, current, { kind: 'append', text })
604 }
605 return
606 }
607 case 'stop':
608 flush()
609 void post(engine, current, { kind: 'status' })
610 return
611 default:
612 return
613 }
614 }
615
616 /**
617 * `source` への Write か Edit が終わった後: 全文を読み直して replace で流し (途中で流した文がずれていても揃う)、
618 * まだ届けていない指摘があれば、`mode` を問わず Tool result の `context` に 1 つ足して届けます。
619 * Write はもう終わっているので、[今すぐ止めて直す] もここでは止めずに届けます。
620 */
621 async function afterWrite(
622 e: { file_path?: unknown; agentId?: string },
623 result: ToolCallResult,
624 ): Promise<ToolCallResult> {
625 const engine = deps.host()
626 const current = live
627 if (!engine || !current || e.agentId !== undefined || result.deny !== undefined || result.isError) {
628 return result
629 }
630 const target = writeTargetOf(e)
631 if (target === null) {
632 return result
633 }
634 const sourceSpellings = [...current.spellings, ...(await deps.spellingsOf(engine, current.input.source))]
635 if (!isSamePath(await deps.spellingsOf(engine, target), sourceSpellings)) {
636 return result
637 }
638 const isAbsolute = target.startsWith('/') || target.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(target)
639 const text = await engine.readFile(isAbsolute ? target : `${deps.cwd()}/${target}`).catch(() => null)
640 if (live !== current) {
641 return result
642 }
643 if (text !== null) {
644 const tail = text.length > LIVE_MAX_TEXT ? text.slice(text.length - LIVE_MAX_TEXT) : text
645 await post(engine, current, { kind: 'replace', text: tail }, false)
646 }
647 await post(engine, current, { kind: 'status', text: STRINGS.liveWriting, phase: 'writing' }, false)
648 if (live !== current || current.isStopping || current.comments.length === 0) {
649 return result
650 }
651 const due = current.comments
652 current.comments = []
653 await record(engine, current, due, 'context')
654 void post(engine, current, { kind: 'status', delivered: due.map(comment => comment.id) }, false)
655 return { ...result, context: [...(result.context ?? []), afterContextOf(due)] }
656 }
657
658 return {
659 /** 開いているライブ表示 (無ければ null) */
660 current: (): Live | null => live,
661 elapsedSeconds: (): number => elapsedSeconds,
662 open,
663 close,
664 takeForReview,
665 observeStep,
666 afterWrite,
667 onTurnStart: (turnId: string) => {
668 mainTurnId = turnId
669 },
670 /**
671 * main の turn の終わり: 「書き終わりました」か「中断しました」を流します。
672 * [今すぐ止めて直す] で止めた turn は、止めた側が状態を流すので触りません。
673 */
674 onTurnComplete: (e: { agentId?: string; turnId: string; reason: string }) => {
675 if (e.agentId !== undefined) {
676 return
677 }
678 if (mainTurnId === e.turnId) {
679 mainTurnId = null
680 }
681 const engine = deps.host()
682 const current = live
683 if (!engine || !current || current.isStopping || current.stoppedTurnId === e.turnId) {
684 return
685 }
686 if (e.reason === 'answer') {
687 void post(engine, current, { kind: 'status', text: STRINGS.liveDone, phase: 'done' })
688 if (current.comments.length > 0) {
689 engine.uiLog(STRINGS.liveUndeliveredOf(current.comments.length))
690 }
691 } else if (e.reason === 'aborted') {
692 void post(engine, current, { kind: 'status', text: STRINGS.liveAborted, phase: 'aborted' })
693 }
694 },
695 /**
696 * セッションの終わり: 受信サーバは止めず (人がまだ読んでいるかもしれない)、状態だけ待たずに投げます。
697 * 受信サーバは Mod からの接触が 10 分途絶えると自分で終わります。
698 */
699 onSessionEnd: () => {
700 const engine = deps.host()
701 const current = live
702 if (engine && current) {
703 void post(engine, current, { kind: 'status', text: STRINGS.liveSessionEnded, phase: 'ended' })
704 }
705 },
706 }
707}
708
709export type LiveController = ReturnType<typeof createLiveController>
710hooks/names.ts 56 lines1/**
2 * この Mod が使う固定の名前。plugin.json の name と一致させます。
3 */
4export const PLUGIN_NAME = 'doc-desk'
5
6/**
7 * `$.tool.register` に渡す短い名前。モデルからは `mcp__<plugin>__<name>` で見えます。
8 */
9export const TOOL_NAME = 'open_form'
10
11/**
12 * `tool.call` の絞り込みに使う、モデルから見た完全なツール名。
13 */
14export const FULL_TOOL_NAME = `mcp__${PLUGIN_NAME}__${TOOL_NAME}`
15
16/**
17 * 指摘の画面を出すツールの短い名前。
18 */
19export const REVIEW_TOOL_NAME = 'open_review'
20
21/**
22 * `tool.call` の絞り込みに使う、指摘の画面のツールの完全な名前。
23 */
24export const FULL_REVIEW_TOOL_NAME = `mcp__${PLUGIN_NAME}__${REVIEW_TOOL_NAME}`
25
26/**
27 * ライブ表示 (書いている文書をブラウザに流す) を開くツールの短い名前。
28 */
29export const LIVE_TOOL_NAME = 'open_live'
30
31/**
32 * `tool.call` の絞り込みに使う、ライブ表示のツールの完全な名前。
33 */
34export const FULL_LIVE_TOOL_NAME = `mcp__${PLUGIN_NAME}__${LIVE_TOOL_NAME}`
35
36/**
37 * `/doc-desk-resume` コマンドの名前。
38 */
39export const COMMAND_NAME = 'doc-desk-resume'
40
41/**
42 * `$.ui.open` に渡すペインの id。`ui.render` の `requestId` と一致します。
43 */
44export const PANE_ID = 'doc-desk'
45
46/**
47 * 証跡ファイルを置くディレクトリ (セッションの cwd 基準)。
48 */
49export const EVIDENCE_DIR = 'doc-desk'
50
51/**
52 * 回答固定形の先頭に置く見出し語。
53 */
54export const REPLY_HEADING = '【doc-desk 回答】'
55
56hooks/receiver/index.ts 192 lines1/**
2 * 受信サーバ (scripts/receiver.py) の起動引数と、起動時に印字する 1 行の読み取り、URL。
3 *
4 * Mod はシェル (sh) を使わず、`<python> receiver.py <サブコマンド>` だけを `$.process.run` に渡します。
5 * 背景での起動、ブラウザ、ファイルの削除、プロセスの停止の OS ごとの違いは receiver.py が吸収するので、
6 * macOS、Linux、Windows で同じ argv になります。
7 */
8
9/**
10 * 受信サーバを起動するのに要るパス。
11 */
12export type ReceiverPaths = {
13 /** plugin.json のあるディレクトリ (絶対) */
14 pluginRoot: string
15 /** 配る HTML (絶対) */
16 html: string
17 /** 回答を書く先 (絶対) */
18 out: string
19}
20
21/**
22 * `receiver.py start` が stdout に印字する 1 行の中身。
23 */
24export type ReceiverInfo = {
25 port: number
26 pid: number
27}
28
29/**
30 * Python 3 を起動する argv の候補。先に見つかったものを使います。
31 *
32 * Windows の `python3` は、Python が入っていても Microsoft Store の案内だけを出す
33 * スタブに当たることがあるので、`python` と `py -3` (Windows の Python ランチャー) も試します。
34 */
35export const PYTHON_CANDIDATES: readonly (readonly string[])[] = [['python3'], ['python'], ['py', '-3']]
36
37/**
38 * `<python> --version` の結果が Python 3 のものか。スタブの案内文や Python 2 は落とします。
39 */
40export const isPython3 = (result: { exitCode: number; stdout: string; stderr: string }): boolean =>
41 result.exitCode === 0 && /^Python 3\./m.test(`${result.stdout}\n${result.stderr}`)
42
43const scriptOf = (pluginRoot: string): string => `${pluginRoot}/scripts/receiver.py`
44
45/**
46 * 受信サーバを起動する前に、同じ label の前回の回答ファイルを消す argv。
47 * 消さないと、同じ label を使い回したときに Mod が前回の回答を新しい回答として拾います。
48 */
49export const cleanupArgv = (python: readonly string[], pluginRoot: string, paths: Pick<ReceiverPaths, 'out'>): string[] => [
50 ...python,
51 scriptOf(pluginRoot),
52 'clean',
53 paths.out,
54]
55
56/**
57 * ファイルを消す argv (receiver.py の `clean`。無いものは飛ばす)。
58 */
59export const removeArgv = (python: readonly string[], pluginRoot: string, paths: readonly string[]): string[] => [
60 ...python,
61 scriptOf(pluginRoot),
62 'clean',
63 ...paths,
64]
65
66/**
67 * 受信サーバを切り離して起動する argv。receiver.py の `start` が `serve` を背景に回し、
68 * `serve` が listen した port と pid を `{"port": n, "pid": n}` の 1 行で stdout に出して戻ります。
69 * 3 秒のうちに listen できなければ何も出しません。
70 *
71 * @param python Python 3 を起動する argv (`PYTHON_CANDIDATES` のどれか)
72 * @param paths ファイルの置き場
73 * @param token `?t=` で照合するトークン
74 * @param preferredPort 使いたい port。塞がっていれば receiver.py が OS に選ばせる
75 * @returns `$.process.run` に渡す argv
76 */
77export function receiverArgv(
78 python: readonly string[],
79 paths: ReceiverPaths,
80 token: string,
81 preferredPort?: number,
82): string[] {
83 return [
84 ...python,
85 scriptOf(paths.pluginRoot),
86 'start',
87 '--token',
88 token,
89 '--html',
90 paths.html,
91 '--out',
92 paths.out,
93 // 整数の 1〜65535 だけを渡す (それ以外を渡すと argparse が終了コード 2 で終わり、起動失敗の理由が分かりにくい)
94 ...(preferredPort !== undefined && Number.isInteger(preferredPort) && preferredPort > 0 && preferredPort <= 65535
95 ? ['--port', String(preferredPort)]
96 : []),
97 ]
98}
99
100/**
101 * ライブ表示の受信サーバを切り離して起動する argv (`start --live`)。回答は受けないので `--out` はありません。
102 * pid と token はどこにも残しません。受信サーバは HTML の書き換えか Mod からの接触の途絶で自分で終わります。
103 *
104 * @param python Python 3 を起動する argv
105 * @param paths plugin.json のあるディレクトリとライブ表示の HTML
106 * @param token `?t=` で照合するトークン
107 */
108export const liveReceiverArgv = (
109 python: readonly string[],
110 paths: Pick<ReceiverPaths, 'pluginRoot' | 'html'>,
111 token: string,
112): string[] => [...python, scriptOf(paths.pluginRoot), 'start', '--live', '--token', token, '--html', paths.html]
113
114/**
115 * `receiver.py start` の stdout を読みます。最初の空でない行が `{"port": n, "pid": n}` でなければ null。
116 */
117export function parseStartOutput(stdout: string): ReceiverInfo | null {
118 const line = stdout.split(/\r?\n/).find(text => text.trim() !== '') ?? ''
119 try {
120 const parsed: unknown = JSON.parse(line)
121 if (
122 typeof parsed === 'object' &&
123 parsed !== null &&
124 Number.isInteger((parsed as { port?: unknown }).port) &&
125 Number.isInteger((parsed as { pid?: unknown }).pid)
126 ) {
127 const { port, pid } = parsed as { port: number; pid: number }
128 return port > 0 && port <= 65535 && pid > 0 ? { port, pid } : null
129 }
130 } catch {
131 // JSON でなければ null
132 }
133 return null
134}
135
136/**
137 * ブラウザに開かせる URL。受信サーバは 127.0.0.1 にだけ bind しています。
138 */
139export const urlOf = (port: number, token: string): string =>
140 `http://127.0.0.1:${port}/?t=${encodeURIComponent(token)}`
141
142/**
143 * ペインの `Link` に置く URL。`Link` の `href` は `https:` か `http://localhost` しか
144 * 通らない (claude-code.d.ts の LinkProps) ので、127.0.0.1 の代わりに localhost で書きます。
145 *
146 * 受信サーバは 127.0.0.1 (IPv4) にだけ bind しているため、ブラウザが localhost を
147 * ::1 (IPv6) に先に解決しても、接続拒否のあと 127.0.0.1 に切り替わることを当てにします
148 * (curl では 127.0.0.1 に届くことを確認済み。主要ブラウザは両方を試します)。
149 */
150export const linkUrlOf = (port: number, token: string): string =>
151 `http://localhost:${port}/?t=${encodeURIComponent(token)}`
152
153/**
154 * 同期待ちのロングポーリング `GET /wait` の URL。受信サーバは回答の POST か
155 * `timeout` 秒の経過まで応答を保留します。
156 */
157export const waitUrlOf = (port: number, token: string, timeoutSeconds: number): string =>
158 `http://127.0.0.1:${port}/wait?t=${encodeURIComponent(token)}&timeout=${timeoutSeconds}`
159
160/**
161 * ライブ表示の受信サーバへ文書を送る `POST /document` の URL。応答に人のライブ指摘と「止めて」の印が載ります。
162 */
163export const documentUrlOf = (port: number, token: string): string =>
164 `http://127.0.0.1:${port}/document?t=${encodeURIComponent(token)}`
165
166/**
167 * ライブ表示を終える `POST /finish` の URL。本文 `{}` で未渡しの指摘を受け取り、`{ url }` でタブを移して終了させます。
168 */
169export const finishUrlOf = (port: number, token: string): string =>
170 `http://127.0.0.1:${port}/finish?t=${encodeURIComponent(token)}`
171
172/**
173 * ブラウザを開く argv。receiver.py の `open` が OS ごとの方法 (macOS は open、
174 * Windows は関連付け、それ以外は xdg-open) で開きます。
175 */
176export const openBrowserArgv = (python: readonly string[], pluginRoot: string, url: string): string[] => [
177 ...python,
178 scriptOf(pluginRoot),
179 'open',
180 url,
181]
182
183/**
184 * 受信サーバを止める argv。receiver.py の `stop` が止めます (もう無ければ何もしません)。
185 */
186export const stopArgv = (python: readonly string[], pluginRoot: string, pid: number): string[] => [
187 ...python,
188 scriptOf(pluginRoot),
189 'stop',
190 String(pid),
191]
192hooks/reply/format.ts 84 lines1import { REPLY_HEADING } from '../names'
2import type { AnswerV1, FormV1 } from '../form/form-v1'
3import { questionsOf } from '../form/form-v1'
4
5/**
6 * 末尾の締めの 1 文。不変です。
7 */
8export const REPLY_CLOSING =
9 '上の回答を反映して文書を作成してください。お任せの項目は推奨案で確定してください。'
10
11/**
12 * 改行を空白 1 つに畳みます (補足と表のセル用)。
13 * 人の記述の文字は改変せず、改行だけを畳みます。
14 */
15const flattened = (text: string): string => text.replace(/\r?\n/g, ' ')
16
17/**
18 * 表のセルの `|` を `\|` に逃がします。
19 */
20const cellOf = (text: string): string => flattened(text).replace(/\|/g, '\\|')
21
22/**
23 * Markdown の表の 1 行。空のセルは `| |` (空白 1 つ) のまま載せます。
24 */
25const rowOf = (cells: string[]): string =>
26 `|${cells.map(cell => (cell === '' ? ' ' : ` ${cell} `)).join('|')}|`
27
28/**
29 * 回答 JSON と質問票から回答固定形 v1 (references/reply-format-v1.md) を作ります。
30 *
31 * - `Qn.` は質問票の並び順 (テーマ順 → 問い順) で 1 から振ります。
32 * - 選択肢は `<id> — <label>`。補足が空なら ` / 補足:` を省きます。
33 * - `## 表` は表が 1 つ以上あるときだけ出します。
34 * - `全体へのコメント:` は空なら行ごと省きます。複数行はそのまま載せます。
35 * - 回答に質問票に無い ID があれば無視し、回答に無い問いは未選択として扱います。
36 *
37 * @param form 検証済みの質問票
38 * @param answer ブラウザが POST した回答
39 * @returns 固定形の全文 (末尾に改行なし)
40 */
41export function formatReply(form: FormV1, answer: AnswerV1): string {
42 const lines: string[] = [`${REPLY_HEADING}${form.documentId}`]
43
44 questionsOf(form).forEach((question, index) => {
45 const given = answer.answers?.[question.id]
46 const choice = given?.choice ?? null
47 const note = typeof given?.note === 'string' ? flattened(given.note) : ''
48 const option = choice === null ? undefined : question.options.find(o => o.id === choice)
49
50 const picked = option ? `${option.id} — ${option.label}` : '(未選択 = お任せ)'
51 const suffix = note.trim() === '' ? '' : ` / 補足: ${note}`
52
53 lines.push(`Q${index + 1}. ${question.title}: ${picked}${suffix}`)
54 })
55
56 const tables = form.tables ?? []
57 if (tables.length > 0) {
58 lines.push('## 表')
59 for (const table of tables) {
60 const filled = answer.tables?.[table.id]
61 lines.push(`### ${table.title}`)
62 lines.push(rowOf(table.columns.map(cellOf)))
63 lines.push(`|${table.columns.map(() => '---').join('|')}|`)
64 table.rows.forEach((row, rowIndex) => {
65 const cells = row.map((original, cellIndex) => {
66 const value = filled?.[rowIndex]?.[cellIndex]
67 return cellOf(typeof value === 'string' ? value : original)
68 })
69 lines.push(rowOf(cells))
70 })
71 }
72 }
73
74 const globalNote = typeof answer.globalNote === 'string' ? answer.globalNote : ''
75 if (globalNote.trim() !== '') {
76 lines.push(`全体へのコメント: ${globalNote}`)
77 }
78
79 lines.push('---')
80 lines.push(REPLY_CLOSING)
81
82 return lines.join('\n')
83}
84