SLOPSHOPPER

doc-desk

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

newpanerowsguardcommandtoast
v0.1.0no licenseupdated 2026-09-26masahide/agent-kit/plugins/doc-desk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · doc-desk
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /doc-desk-resume ⎿ doc-desk: 待機中の質問票も指摘の画面も、開いているライブ表示もありません ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

doc-desk

使い方 (読み込み方、フォームの使い方、困ったとき) は 利用者ガイド にあります。

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.tssession.start で $ を束ねた関数群の型
hooks/guard/paths.ts回答待ちの書き込みを止めるパスの正規化 (\ と /、..、Windows の大文字小文字) と照合
hooks/compact/record.ts圧縮の結果に差し戻す 【doc-desk 決定の記録】 の組み立て (文字数の上限と切り詰め)
hooks/store/pending-record.ts$.store に残す待機の記録の型、読み取り、古さの判定 (7 日)
hooks/names.tsplugin 名、ツール名、コマンド名、ペイン 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.ts2 つの画面が共有する 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.tsreview の検証 (エラーを全部返す)
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.tsPython 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.tsMcpToolInputs にツールの入力を足す宣言 (型付けのみ)
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

What it hooks

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=? と印字します。

eventwhat 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_reviewreview を検証し、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.startmain の 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.compactmain の会話 (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 を読んで照合します。

What it calls on $

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>.htmlHTML シート (トークンは埋めない。ブラウザの 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.mdja-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 と一致)、反映と完了報告

Try it

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.

Source 32 files
hooks/register.ts 1380 lines
1import 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 lines
1import 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}
31
hooks/form/form-v1.ts 107 lines
1/**
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}
107
hooks/form/schema.ts 228 lines
1/**
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
228
hooks/form/validate.ts 267 lines
1import 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}
267
hooks/compact/record.ts 120 lines
1import { 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}
120
hooks/guard/paths.ts 64 lines
1/**
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))
64
hooks/host/index.ts 76 lines
1import 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}
76
hooks/live/controller.ts 710 lines
1import 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>
710
hooks/names.ts 56 lines
1/**
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
56
hooks/receiver/index.ts 192 lines
1/**
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]
192
hooks/reply/format.ts 84 lines
1import { 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