peertable 0.4.10 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -107,9 +107,9 @@ Codex では、スキルが所有する room MCP block をプロジェクトの
107
107
 
108
108
  動いており、**自分自身の開発に使っている**。2026-08-08 に end-to-end 検証済み——オーケストレーターなしの完全な一周(2 メンバーが相談し、claim し、インターフェースを交渉し、見つけた罠を共有して小さなプロジェクトを出荷)を**外部介入ゼロ**で完走。2026-08-13の実席ライフサイクルでは、作業席が親を通じてsession contextを保ったままmodel / effortを変更し、再起動後はroomと工程正本から再着任した。2026-08-14にはGrok 4.6席の着席、room参加、同一sessionの4.6↔4.5変更、DM起床を実機で確認した。2026-08-17にGrok席はidle待ち、broadcastは本文を残し、tmuxの無い親でbridge cursorが止まらないよう直した。
109
109
 
110
- 現在のnpm releaseは **peertable 0.4.10**。
110
+ 現在のnpm releaseは **peertable 0.4.12**。
111
111
 
112
- 設計文書と決定履歴(**89 決定**)は [docs/plan.md](docs/plan.md)。
112
+ 設計文書と決定履歴(**91 決定**)は [docs/plan.md](docs/plan.md)。
113
113
 
114
114
  Claude Code channels はリサーチプレビューのため、フラグ・プロトコルは変わりうる。
115
115
 
package/README.md CHANGED
@@ -143,9 +143,9 @@ It interviews you, names the members, scaffolds `.team/` (charter + roles, isola
143
143
 
144
144
  Working, and used to build itself. First verified end-to-end on 2026-08-08 with a full no-orchestrator loop: two members consulted, claimed, negotiated an interface, shared a discovered pitfall, and shipped a small project with **zero external intervention**. A 2026-08-13 real-seat lifecycle verified in-place model/effort changes and restart recovery. On 2026-08-14, a Grok 4.6 seat joined the room, changed 4.6↔4.5 in the same session, and woke on a direct message in a live acceptance run. On 2026-08-17 the wake-up path was corrected so Grok seats wait for idle, broadcasts keep their body, and a parent without a tmux seat cannot stall the bridge cursor.
145
145
 
146
- The current npm release is **peertable 0.4.10**.
146
+ The current npm release is **peertable 0.4.12**.
147
147
 
148
- The design document and decision log (**89 decisions**, in Japanese) live in [docs/plan.md](docs/plan.md).
148
+ The design document and decision log (**91 decisions**, in Japanese) live in [docs/plan.md](docs/plan.md).
149
149
 
150
150
  Depends on Claude Code **channels**, currently a research preview — flags and protocol may change.
151
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code, Codex, and Grok sessions into a team of equal, long-lived peers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/room/client.mjs CHANGED
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url'
11
11
 
12
12
  // client.mjs 側のハードコード版数。package.json の version と一致していることを
13
13
  // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
14
- const MCP_VERSION = '0.4.10'
14
+ const MCP_VERSION = '0.4.12'
15
15
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
16
16
 
17
17
  const USAGE = `usage:
@@ -287,9 +287,11 @@ async function runDiagnostics(asJson) {
287
287
  'scripts/teardown.sh',
288
288
  'scripts/external-pane.mjs',
289
289
  'scripts/launch-seat.sh',
290
+ 'scripts/resolve-seat-placement.mjs',
290
291
  'scripts/tmux-at.bash',
291
292
  'scripts/tmux-socket.mjs',
292
293
  'scripts/seat-identity.mjs',
294
+ 'scripts/pid-alive.mjs',
293
295
  'scripts/parent-watch.mjs',
294
296
  'scripts/seat-credential.mjs',
295
297
  'scripts/ensure-room-mcp.mjs',
package/skill/SKILL.md CHANGED
@@ -28,7 +28,7 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
28
28
  ## Peertableの正規席と委譲入口
29
29
 
30
30
  Peertableのメンバー席は、aiterm-mcpの外部PTYに長寿命で着席させる。新しい席は
31
- `env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> <model> <vendor> <effort> [brief]`で起こし、既存席の分担・起床・確認は
31
+ `env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> <role> [brief]`で起こし、既存席の分担・起床・確認は
32
32
  `mcp__aiterm__pty_read` / `mcp__aiterm__pty_send` / `mcp__aiterm__pty_key`、roomの`read_unread` / `post`、
33
33
  Latticeの`todo`で行う。通常shellのために開いた短命PTYと、メンバーが着席する長寿命PTYは別物である。
34
34
 
@@ -49,7 +49,7 @@ script は内部で Aiterm の公開 `claude_agent` / `codex_agent` / `grok_agen
49
49
 
50
50
  手順は **聞き取り → script → 着任指示** の3段である。scripts が機械部分を全部持つので、AI が手で tmux を組み立てることはしない。
51
51
 
52
- 1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数とモデル・effort(モデル×effortの選定は dotagents `docs/02_models.md` の役割→順位表を正とし、席の主任務の役割(実装・局所コーディング・監査・反証・調査 等)で引く。**effort は既定値を置かない——席ごとに必ず決める**。`launch-seat.sh` effort は必須引数で、未指定は着席前に非ゼロ終了する。決定49改・2026-08-19: 旧「設計か確定実装か」の2値軸と既定Sonnetを廃止し、選定正本を02_models順位表へ移管)/ 初期タスク群(何を作るか)/ room 名(既定: プロジェクトのディレクトリ名)/ **公開URL基底**(Lattice 併用のみ。外部ペインに書く URL。クオ環境は `https://peertable.kitepon.dev`。未指定なら room サーバーの URL がそのまま入る=LAN URL は Lattice を外から見た時に開けない)
52
+ 1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数と**役割**(dotagents `docs/02_models.md` の役割名そのもの。未指定・未知は着席しない。model×effort `launch-seat.sh` が順位表1位から機械解決する——呼び出し側が model / vendor / effort を渡して正本を迂回しない。決定49改・決定91)/ 初期タスク群(何を作るか)/ room 名(既定: プロジェクトのディレクトリ名)/ **公開URL基底**(Lattice 併用のみ。外部ペインに書く URL。クオ環境は `https://peertable.kitepon.dev`。未指定なら room サーバーの URL がそのまま入る=LAN URL は Lattice を外から見た時に開けない)
53
53
  - **メンバー数の既定**: Lattice 併用なら plan compile 結果の幅(`max_frontier_width`)に合わせる(実測: 幅3→3人、第2 campaign で幅4→4人目追加)。frontier より多い席は最初から遊ぶ。単独モードには frontier が無いので既定の根拠も無く、聞き取りで決める
54
54
  - **運用中のworker席数の標準は「ready+activeな実装ToDo数」(決定68)**: 監査専任席はworker数、reclaim、scale-down候補へ含めず、最終試験結果を待って監査を担う。claimできるToDoが無いworker席は仕事を発明せず、最終手段として親だけへ待機DMする
55
55
  - **モードの選び分け**: タスク間に依存があり並列境界の機械保証が要るなら Lattice 併用。依存の無い小規模作業で、対象プロジェクトに Lattice を持ち込みたくないなら単独。単独で失うのは task 間スケジューリングの機械保証だけで、円卓の核(room・憲章・宣言による協力)は変わらない(決定47)
@@ -61,7 +61,7 @@ script は内部で Aiterm の公開 `claude_agent` / `codex_agent` / `grok_agen
61
61
  4. **Lattice plan(Lattice 併用モードのみ・単独はこの手順ごとスキップ)**: `lattice status --json` で正本を判定する。`uninitialized` なら聞き取ったタスクを JSON へ落として `scripts/make-plan-input.mjs <tasks.json> --project <project>` で `plan create` 入力を生成し、`lattice plan create --input .lattice/plan-create.json` を打つ。初期化済みなら `todo migrate` の作法(Lattice 正典)に従う。設計メモは各タスクに必ず書く
62
62
  - `make-plan-input.mjs` が digest 計算と `hard_dependencies` の `(from,to)` 昇順ソートを持つ(**手書きで2回踏んだ罠**。順序が崩れると `INPUT_INVALID / pointer:"/"` としか言われない)。`project_id` の既定は project ディレクトリ名で、`external-pane.mjs` が書く `project.json` の既定と一致させてある——**両者がずれると Lattice が identity 検証で落ちる**
63
63
  - 単独モードのタスク正本は手順3で生成した `.team/tasks.md` だけである。状態(誰が持っているか・何が終わったか)は持たせない——claim と完了は room の宣言だけが正(決定48 の延長)。ミニタスクトラッカーを別途作らない(決定36)
64
- 5. **メンバー起動**: メンバーごとに `env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> <model> <claude|codex|grok> <effort> [着任指示]` を実行する。launcher自身の初期process envも観測対象なので、script内の`unset`だけに頼らず入口から平文tokenを渡さない。**vendor・effort とも必須引数**(未指定は usage を出して非ゼロ終了。既定値をコードへ埋めない——席を立てる時に決める)。tmux 作成(aiterm と同じソケットなので、立った席はそのまま `pty_read`/`pty_send` で読める)→ credential file path注入 → 起動 → 既知ダイアログ通過 → 着席確認まで1回で行き、着席しなければ最後の画面を出して非ゼロで落ちる(黙って進まない)
64
+ 5. **メンバー起動**: メンバーごとに `env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> <role> [着任指示]` を実行する。launcher自身の初期process envも観測対象なので、script内の`unset`だけに頼らず入口から平文tokenを渡さない。**role は必須**(02_models の役割名。未指定・`worker` 既定・未知の名前は着席前に非ゼロ終了)。model / vendor / effort は順位表から解決する。tmux 作成(aiterm と同じソケットなので、立った席はそのまま `pty_read`/`pty_send` で読める)→ credential file path注入 → 起動 → 既知ダイアログ通過 → 着席確認まで1回で行き、着席しなければ最後の画面を出して非ゼロで落ちる(黙って進まない)
65
65
  - 起動前に `pty_list` で既存の `peer-*` 席を確認する(前の卓の残骸を99席実測したことがある)。同名の席は launch-seat.sh が落としてから立て直す
66
66
  - 着任指示を第6引数に渡すと着席後に送る。文面: 「あなたは「<日本語名>」。.team/roles/member.md を読んで着任し、作業ループを開始せよ。全タスク完了の宣言まで自律的に続けること。」
67
67
  - 席が読む env は script が組み立てる(`PEERTABLE_URL` / `PEERTABLE_ROOM` / `PEERTABLE_MEMBER` / `PEERTABLE_CREDENTIAL_FILE`、Lattice 併用なら `PEERTABLE_PLAN` と actor 3点)。token値は席別`0600` fileにだけ置き、pathだけを席へ渡す。**channels は `--mcp-config` の MCP server を解決しない**(実測 2026-08-08・Claude Code v2.1.226・決定44)ため、room の MCP 定義は setup.sh が project root へ置く `.mcp.json` が正。Peertable管理下fileはlaunch時にもcurrent-tree clientへ同期する。project に既存 `.mcp.json` があった場合は無断更新せず`SEAT_ROOM_MCP_STALE`で止まるので、AI がroom定義を手動mergeしてteardownで復元する
@@ -230,7 +230,7 @@ witness をどう生成するかは**対象 project 側の作法に従う**(La
230
230
  - evidence は記述子 JSON。記述子ファイル自体も repo 内相対パスに置く(repo 外絶対パスは INVALID_ARGUMENTS)。`.team/scripts/done.sh` が正規経路。証跡の置き場は **`evidence/<plan_key>/<task_id>.md`**——task_id は campaign を跨いで再利用されるので、平置きにすると前の campaign の監査証跡を上書きで消す(2026-08-08 実測)
231
231
  - **外部ペイン(決定53)は Lattice 0.50.0 以降が要る。** それ以前の Lattice に `external_pane` 入りの `project.json` を差すと、identity 検証が完全一致キーで落ちて `lattice todo status` ごと死ぬ(`PROJECT_IDENTITY_INVALID / identity_schema_invalid`・0.49.0 で実測)。工程正本が読めなくなる=卓が止まるので、Lattice が古い環境では Lattice 併用 setup を走らせない
232
232
  - **メンバー起動の既知ダイアログは2種だけ**(実測 2026-08-08): 未信頼ディレクトリの workspace trust(`1. Yes, I trust this folder`)と開発 channel 警告(`1. I am using this for local development`)。`--dangerously-skip-permissions` を付けているので MCP 同意ダイアログは出ない。信頼済みディレクトリでは trust も出ない
233
- - **Codex 席のダイアログは2種で、片方は既定が誤り**(Codex CLI v0.146.0・実測): ディレクトリ trust(`1. Yes, continue`=既定で正しい)と、**更新案内(`1. Update now`)は既定のまま Enter を押すと立卓の途中で `npm install -g @openai/codex` が走る**。1つ下の「2. Skip」を選ぶ。更新案内は毎回は出ないので、出た時だけ通す
233
+ - **Codex 席のダイアログは3種で、更新案内と hooks の既定は誤り**(Codex CLI v0.146.0 および 2026-08-20 実測): ディレクトリ trust(`1. Yes, continue`=既定で正しい)。**更新案内(`1. Update now`)は既定のまま Enter を押すと立卓の途中で `npm install -g @openai/codex` が走る**ので「2. Skip」。**`Hooks need review` は room MCP 初期化より前に出る**(`SEAT_ROOM_MCP_NOT_READY` のまま rollback)。`launch-seat.sh` は「2. Trust all and continue」を通す。失敗時は rollback 前に `.team/<name>-pane-on-fail.txt` を残す。待ち時間のポーリングで代用しない
234
234
  - **Codex はターン実行中でも素送信を受け付ける**(実測 2026-08-08)。busy 中に送った文言はそのターンの中で読まれ、指示どおりに動く(steering)。Codex 席の起床は idle 待ちを持たない
235
235
  - **Grok はターン実行中の素送信を今の仕事へ混ぜない**(実測 2026-08-17・Grok Build TUI 既定 `follow_up_behavior=queue`)。届いた文は入力キューへ積まれ、次の user ターンになる。起床ブリッジは Grok 席だけ pane が idle になるまで送らない。busy の判定は `esc to interrupt`、待ち中の `send a message to interrupt`、番号付きキュー+`Enter:send now`
236
236
  - **席の沈黙は「詰まり」と同義ではない。** 発言間隔やファイルの更新時刻から止まったと判定しない——実装が終わって検証に時間を使っているだけのことがある。判定は `tmux_at capture-pane -t peer-<名前> -p`(POSIX は `tmux -S <sock>`、Windows psmux は `tmux -L aiterm-<hash>`。決定88)で**実状態を読む**: 画面に **`esc to interrupt` が在れば長いターンの最中**(通知はターン後にまとめて届くので、呼びかけを足しても速くならない)/選択ダイアログで止まっているなら既知の停止要因/`pane_dead=1` なら落ちている。**スピナーの動名詞(`Cogitating…` 等)で判定しない**——毎回ランダムなので語そのものは使わない(2026-08-08 実測)。Fable のツール実行中は `… (7m 48s` の経過時間行と `/btw` の `without interrupting Claude's current work` が固定句として出る。思考中は `thinking with` / `almost done thinking`。**`esc to interrupt` は Claude 席のステータス行にも Codex 席の `Working (…)` にも入る共通マーカー**で、vendor 分岐が要らない。**読み取りだけなら相手の作業を壊さない**ので、憶測を room へ流す前にこれを見る(2026-08-08 に2人が独立に踏み、先に憶測を流した側が訂正を出した)
@@ -16,7 +16,7 @@ if (!session_id || (!model && !reasoning_effort)) {
16
16
  // Aiterm のmanaged session metadataは実行時state root(macOSではTMPDIR)にある。
17
17
  // stdio transportの既定環境は親のTMPDIRを落とすため、同じ公開serverへ明示継承する。
18
18
  const transport = new StdioClientTransport({ command: 'aiterm-mcp', env: process.env })
19
- const client = new Client({ name: 'peertable-seat-configure', version: '0.4.10' })
19
+ const client = new Client({ name: 'peertable-seat-configure', version: '0.4.12' })
20
20
  await client.connect(transport)
21
21
  const result = await client.callTool({
22
22
  name: 'agent_configure',
@@ -8,7 +8,7 @@ if (!session_name || !['claude', 'codex', 'grok'].includes(vendor) || !model ||
8
8
  throw new Error('SEAT_AITERM_LAUNCH_ARGS_INVALID')
9
9
  }
10
10
 
11
- const client = new Client({ name: 'peertable-seat-launch', version: '0.4.10' })
11
+ const client = new Client({ name: 'peertable-seat-launch', version: '0.4.12' })
12
12
  await client.connect(new StdioClientTransport({ command: 'aiterm-mcp', env: process.env }))
13
13
  const env_vars = [
14
14
  'PEERTABLE_URL', 'PEERTABLE_ROOM', 'PEERTABLE_MEMBER', 'PEERTABLE_CREDENTIAL_FILE',
@@ -59,9 +59,9 @@ name=sys.argv[1]
59
59
  member=next((m for m in json.load(sys.stdin).get("members",[]) if m.get("name")==name),None)
60
60
  if not member or member.get("vendor") not in ("claude","codex","grok") or not member.get("model"):
61
61
  raise SystemExit(1)
62
- print("\t".join((member["vendor"],member["model"],member.get("effort") or "",member.get("aiterm_session_id") or "")))
62
+ print("\t".join((member["vendor"],member["model"],member.get("effort") or "",member.get("aiterm_session_id") or "",member.get("role") or "")))
63
63
  ' "$name") || { echo "SEAT_CHANGE_MEMBER_METADATA_MISSING: ${name} のvendor/modelが要る" >&2; exit 1; }
64
- IFS=$'\t' read -r old_vendor old_model old_effort aiterm_session_id <<EOF
64
+ IFS=$'\t' read -r old_vendor old_model old_effort aiterm_session_id old_role <<EOF
65
65
  $meta
66
66
  EOF
67
67
 
@@ -202,11 +202,15 @@ else
202
202
  exit 1
203
203
  fi
204
204
  launch="$script_dir/launch-seat.sh"
205
+ [ -n "$old_role" ] || {
206
+ echo "SEAT_CHANGE_ROLE_MISSING: ${name} の role が無い(02_models の役割名が要る)" >&2
207
+ exit 1
208
+ }
205
209
  brief="席設定が変更され(${changes})、席を再起動しました。.team/roles/member.mdと工程正本・roomログから再着任し、進行中taskを続けてください。"
206
- if ! "$launch" "$proj" "$name" "$model" "$vendor" "$effort" "$brief"; then
210
+ if ! "$launch" "$proj" "$name" "$old_role" "$brief" --model "$model" --vendor "$vendor" --effort "$effort"; then
207
211
  echo "SEAT_CHANGE_RESTART_FAILED: ${changes}。旧設定(vendor=${old_vendor} / model=${old_model} / effort=${old_effort:-default})へrollbackする" >&2
208
212
  rollback_brief="席設定の変更に失敗して旧設定へrollbackしました。.team/roles/member.mdと工程正本・roomログから再着任してください。"
209
- if "$launch" "$proj" "$name" "$old_model" "$old_vendor" "$old_effort" "$rollback_brief"; then
213
+ if "$launch" "$proj" "$name" "$old_role" "$rollback_brief" --model "$old_model" --vendor "$old_vendor" --effort "$old_effort"; then
210
214
  echo "SEAT_CHANGE_ROLLED_BACK: ${name} は vendor=${old_vendor} / model=${old_model} / effort=${old_effort:-default} で再着席" >&2
211
215
  else
212
216
  echo "SEAT_CHANGE_ROLLBACK_FAILED: ${name} の席を手動で復旧する必要がある" >&2
@@ -18,7 +18,7 @@ if [ $# -eq 0 ] && [ -f "$record" ]; then
18
18
  fi
19
19
  if [ -f "$record" ]; then
20
20
  pid=$(node -e 'try{process.stdout.write(String(require(process.argv[1]).pid||""))}catch{}' "$record")
21
- if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then exit 0; fi
21
+ if [ -n "$pid" ] && node "$(dirname "$0")/pid-alive.mjs" "$pid"; then exit 0; fi
22
22
  if ! "$force" && grep -q 'WRITE_DENIED' "$log" 2>/dev/null; then echo "${name}-bridge: 前回はWRITE_DENIEDで終了。--forceを指定すること" >&2; exit 1; fi
23
23
  fi
24
24
  # shellcheck disable=SC1091
@@ -30,7 +30,10 @@ session="peertable-${name}-${room}"
30
30
  # 新しい bridge が1バイトも動いていない段階で success を返す——「起動していないのに
31
31
  # 起動したと言う」=この supervisor が塞ぐはずの穴そのものになる。
32
32
  rm -f "$record"
33
- : > "$log"
33
+ if ! node -e 'import { writeFileSync } from "node:fs"; writeFileSync(process.argv[1], "")' "$log"; then
34
+ echo "${name}-bridge: log を切り詰められないので追記する" >&2
35
+ : >> "$log" || true
36
+ fi
34
37
  # **手渡された env を常駐へ渡す。** 新しい session が継ぐのは tmux *server* の環境であって
35
38
  # 呼び出し元 client の環境ではないので、素で起こすと `PEERTABLE_TMUX_SOCKET` の手渡しが黙って消え、
36
39
  # 常駐が別の socket(本番の既定)を観測しにいく(2026-08-11 実測)。決定73 と同じ形の裏返しである。
@@ -64,6 +64,7 @@ function expectedBlock(addedNewline) {
64
64
  const explicitEnv = seatEnvNames
65
65
  .filter((name) => process.env[name] !== undefined && process.env[name] !== '')
66
66
  .map((name) => `${name} = ${JSON.stringify(process.env[name])}`)
67
+ if (process.env.PATH) explicitEnv.unshift(`PATH = ${JSON.stringify(process.env.PATH)}`)
67
68
  return [
68
69
  `# BEGIN PEERTABLE ROOM MCP added_newline=${addedNewline ? '1' : '0'}`,
69
70
  '[mcp_servers.room]',
@@ -1,25 +1,41 @@
1
1
  #!/bin/bash
2
2
  # 席を1つ立てる(tmux 作成 → env 注入 → エージェント起動 → 既知ダイアログ通過 → 着席確認)。
3
- # usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief] [role]
4
- # vendor: claude / codex / grok
5
- # effort: 必須。既定値をコードへ埋めない——席を立てる時に決める(オーナー裁定)
3
+ # usage: launch-seat.sh <project_dir> <name> <role> [brief] [--model <model> --vendor <vendor> --effort <effort>]
4
+ # role: 必須。dotagents docs/02_models.md の役割名(未指定・未知は着席前に拒否)
5
+ # model / vendor / effort: 省略時は 02_models 順位表から機械解決する。上書きするなら3つとも必須
6
6
  # brief: 着席が成立したら送る着任指示(省略時は送らない)
7
7
  #
8
8
  # room 名・server URL・モード・plan key は <project>/.team/setup-state.json から読む(setup.sh の後に呼ぶ)。
9
9
  # 書込トークンは helper が ~/.config/peertable.env から席別 0600 file へ移し、pathだけを席へ渡す。
10
10
  # tmux は aiterm-mcp と同じソケットへ作るので、立てた席はそのまま pty_read / pty_send で読める。
11
11
  set -e
12
- proj="$1"; name="$2"; model="$3"; vendor="$4"; effort="$5"; brief="$6"; role="${7:-worker}"
13
- [ -n "$proj" ] && [ -n "$name" ] && [ -n "$model" ] && [ -n "$vendor" ] && [ -n "$effort" ] || {
14
- echo "usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief]" >&2; exit 1;
12
+ usage() {
13
+ echo "usage: launch-seat.sh <project_dir> <name> <role> [brief] [--model <model> --vendor <vendor> --effort <effort>]" >&2
15
14
  }
16
- if [ "$vendor" = "claude" ]; then
17
- case "$effort" in
18
- low|medium|high|xhigh|max) ;;
19
- *) echo "unknown effort: ${effort}(claude は low|medium|high|xhigh|max)" >&2; exit 1 ;;
15
+ proj="$1"; name="$2"; role="$3"
16
+ [ -n "$proj" ] && [ -n "$name" ] && [ -n "$role" ] || { usage; exit 1; }
17
+ shift 3
18
+ brief=""
19
+ opt_model=""; opt_vendor=""; opt_effort=""
20
+ while [ $# -gt 0 ]; do
21
+ case "$1" in
22
+ --model) opt_model="${2:-}"; shift 2 ;;
23
+ --vendor) opt_vendor="${2:-}"; shift 2 ;;
24
+ --effort) opt_effort="${2:-}"; shift 2 ;;
25
+ --*) echo "SEAT_LAUNCH_ARGS_INVALID: 不明な引数 $1" >&2; usage; exit 1 ;;
26
+ *)
27
+ if [ -z "$brief" ]; then brief="$1"; shift
28
+ else echo "SEAT_LAUNCH_ARGS_INVALID: 余分な引数 $1" >&2; usage; exit 1
29
+ fi
30
+ ;;
20
31
  esac
32
+ done
33
+ if [ -n "$opt_model$opt_vendor$opt_effort" ]; then
34
+ [ -n "$opt_model" ] && [ -n "$opt_vendor" ] && [ -n "$opt_effort" ] || {
35
+ echo "SEAT_LAUNCH_PLACEMENT_INCOMPLETE: --model / --vendor / --effort は3つ揃える" >&2
36
+ exit 1
37
+ }
21
38
  fi
22
- case "$role" in worker|auditor) ;; *) echo "unknown seat role: ${role}(worker / auditor)" >&2; exit 1 ;; esac
23
39
 
24
40
  if [ -n "${PEERTABLE_MEMBER:-}" ]; then
25
41
  echo "SEAT_LAUNCH_DELEGATED_CHILD_FORBIDDEN: PEERTABLE_MEMBER=${PEERTABLE_MEMBER} を継承した呼出元からの席起動を拒否" >&2
@@ -33,6 +49,7 @@ credential_helper="${PEERTABLE_CREDENTIAL_HELPER:-$(dirname "$0")/seat-credentia
33
49
  room_mcp_helper="${PEERTABLE_ROOM_MCP_HELPER:-$(dirname "$0")/ensure-room-mcp.mjs}"
34
50
  codex_room_mcp_helper="${PEERTABLE_CODEX_ROOM_MCP_HELPER:-$(dirname "$0")/ensure-codex-room-mcp.mjs}"
35
51
  aiterm_launch_helper="${PEERTABLE_AITERM_LAUNCH_HELPER:-$(dirname "$0")/aiterm-launch.mjs}"
52
+ placement_helper="${PEERTABLE_PLACEMENT_HELPER:-$(dirname "$0")/resolve-seat-placement.mjs}"
36
53
  # shellcheck disable=SC1091
37
54
  . "$(dirname "$0")/tmux-at.bash"
38
55
  peertable_repo=$(cd "$(dirname "$0")/../.." && pwd -P)
@@ -41,6 +58,32 @@ credential_file=""
41
58
  credential_persist=false
42
59
  aiterm_session_id=""
43
60
 
61
+ if [ -n "$opt_model" ]; then
62
+ model="$opt_model"; vendor="$opt_vendor"; effort="$opt_effort"
63
+ echo "SEAT_PLACEMENT_OVERRIDE: ${role} → ${vendor} / ${model} / ${effort}"
64
+ else
65
+ placement=$(node "$placement_helper" "$role") || exit "$?"
66
+ IFS=$'\t' read -r model vendor effort rank <<EOF
67
+ $(python3 -c 'import json,sys;p=json.load(sys.stdin);print("\t".join((p["model"],p["vendor"],p.get("effort") or "",str(p["rank"]))))' <<<"$placement")
68
+ EOF
69
+ echo "SEAT_PLACEMENT: ${role} → ${vendor} / ${model}${effort:+ / $effort}(02_models ${rank}位)"
70
+ python3 -c 'import json,sys
71
+ p=json.load(sys.stdin)
72
+ for item in p.get("dropped") or []:
73
+ print("SEAT_PLACEMENT_DROPPED: rank %s %s %s" % (item.get("rank"), item.get("reason"), item.get("cell") or ""), file=sys.stderr)
74
+ ' <<<"$placement" || true
75
+ fi
76
+ case "$vendor" in
77
+ claude|codex|grok) ;;
78
+ *) echo "SEAT_LAUNCH_VENDOR_UNSUPPORTED: vendor=${vendor}" >&2; exit 1 ;;
79
+ esac
80
+ if [ "$vendor" = "claude" ] && [ -n "$effort" ]; then
81
+ case "$effort" in
82
+ low|medium|high|xhigh|max) ;;
83
+ *) echo "unknown effort: ${effort}(claude は low|medium|high|xhigh|max)" >&2; exit 1 ;;
84
+ esac
85
+ fi
86
+
44
87
  # brief は tmux のコマンド引数へ直接載せない。長さを着席前に検証してから一時 file へ置き、
45
88
  # tmux の buffer 経由で貼る。入力を受理できない時は model preflight・tmux・member 登録を
46
89
  # 一つも行わず、何が拒否されたかを機械的に読める形で返す。
@@ -328,10 +371,49 @@ room_ready_deadline=$((SECONDS + 30))
328
371
  room_ready=false
329
372
  grok_trust_accepted=false
330
373
  mcp_consent_accepted=false
374
+ codex_hooks_accepted=false
375
+ codex_update_accepted=false
376
+ codex_trust_accepted=false
331
377
  while [ $SECONDS -lt "$room_ready_deadline" ]; do
332
378
  # Grok Build は初めて開く作業treeで、room MCPを初期化する前にworkspace trustを尋ねる。
333
379
  # Peertableが正式に着席させるtreeなので、この既知文言だけを一度通す。未知の確認画面を
334
380
  # 汎用的に承認するfallbackにはしない。承認後のMCP初期化時間は改めて30秒確保する。
381
+ if [ "$vendor" = codex ]; then
382
+ codex_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
383
+ if [ "$codex_update_accepted" != true ]; then
384
+ case "$codex_screen" in
385
+ *"Update now"*)
386
+ if tmux_at send-keys -t "$sess" Down && tmux_at send-keys -t "$sess" Enter; then
387
+ codex_update_accepted=true
388
+ room_ready_deadline=$((SECONDS + 30))
389
+ echo "codex update prompt: skipped"
390
+ fi
391
+ ;;
392
+ esac
393
+ fi
394
+ if [ "$codex_hooks_accepted" != true ]; then
395
+ case "$codex_screen" in
396
+ *"Hooks need review"*)
397
+ if tmux_at send-keys -t "$sess" Down && tmux_at send-keys -t "$sess" Enter; then
398
+ codex_hooks_accepted=true
399
+ room_ready_deadline=$((SECONDS + 30))
400
+ echo "codex hooks prompt: trust all"
401
+ fi
402
+ ;;
403
+ esac
404
+ fi
405
+ if [ "$codex_trust_accepted" != true ]; then
406
+ case "$codex_screen" in
407
+ *"Yes, continue"*)
408
+ if tmux_at send-keys -t "$sess" Enter; then
409
+ codex_trust_accepted=true
410
+ room_ready_deadline=$((SECONDS + 30))
411
+ echo "codex directory trust: accepted"
412
+ fi
413
+ ;;
414
+ esac
415
+ fi
416
+ fi
335
417
  if [ "$vendor" = grok ] && [ "$grok_trust_accepted" != true ]; then
336
418
  grok_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
337
419
  case "$grok_screen" in
@@ -371,6 +453,8 @@ while [ $SECONDS -lt "$room_ready_deadline" ]; do
371
453
  done
372
454
  if [ "$room_ready" != true ]; then
373
455
  echo "SEAT_ROOM_MCP_NOT_READY: room member登録を観測できない(無関係MCP warningとroom不成立を分離。席をrollbackする)" >&2
456
+ tmux_at capture-pane -t "$sess" -p >"$proj/.team/${name}-pane-on-fail.txt" 2>/dev/null || true
457
+ echo "pane saved: $proj/.team/${name}-pane-on-fail.txt" >&2
374
458
  exit 1
375
459
  fi
376
460
  echo "room ready: ${room}/${name}"
@@ -460,7 +544,7 @@ seat_pid=""
460
544
  pane_pid=$(tmux_at list-panes -t "$sess" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
461
545
  seat_ident=""
462
546
  if [ -n "$pane_pid" ]; then
463
- seat_ident=$(node "$(dirname "$0")/seat-identity.mjs" "$pane_pid" 2>/dev/null || true)
547
+ seat_ident=$(node "$(dirname "$0")/seat-identity.mjs" "$pane_pid") || seat_ident=""
464
548
  fi
465
549
  if [ -n "$seat_ident" ]; then
466
550
  seat_pid=$(python3 -c "import json,sys;print(json.load(sys.stdin)['pid'])" <<<"$seat_ident")
@@ -120,6 +120,16 @@ let state = await ensurePrimed()
120
120
  if (mode === '--prime') process.exit(0)
121
121
 
122
122
  const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
123
+ function stopPid(pid) {
124
+ try { process.kill(pid, 'SIGTERM') } catch { return }
125
+ const deadline = Date.now() + 2000
126
+ while (Date.now() < deadline && alive(pid)) {
127
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
128
+ }
129
+ if (alive(pid)) {
130
+ try { process.kill(pid, 'SIGKILL') } catch {}
131
+ }
132
+ }
123
133
  function acquireLock() {
124
134
  try {
125
135
  writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 })
@@ -127,13 +137,13 @@ function acquireLock() {
127
137
  if (error.code !== 'EEXIST') throw error
128
138
  let owner = null
129
139
  try { owner = Number(readFileSync(lockPath, 'utf8').trim()) } catch {}
130
- if (Number.isSafeInteger(owner) && !alive(owner)) {
131
- unlinkSync(lockPath)
132
- writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 })
133
- return
140
+ if (Number.isSafeInteger(owner) && owner === process.pid) return
141
+ if (Number.isSafeInteger(owner) && alive(owner)) {
142
+ process.stderr.write(`PARENT_WATCH_REPLACING: pid ${owner}\n`)
143
+ stopPid(owner)
134
144
  }
135
- console.error(`PARENT_WATCH_ALREADY_RUNNING: pid ${Number.isSafeInteger(owner) ? owner : '不明'}`)
136
- process.exit(1)
145
+ try { unlinkSync(lockPath) } catch {}
146
+ writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 })
137
147
  }
138
148
  }
139
149
  function releaseLock() {
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // Windows Git Bash の kill -0 は Win32 pid を見ない。Node の process.kill は見る。
3
+ const pid = Number(process.argv[2])
4
+ if (!Number.isInteger(pid) || pid <= 0) process.exit(2)
5
+ try {
6
+ process.kill(pid, 0)
7
+ process.exit(0)
8
+ } catch {
9
+ process.exit(1)
10
+ }
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // 02_models.md の役割→1位〜3位から、着席可能な vendor / model / effort を解決する。
3
+ // 具体モデル名はここに持たない。台帳と順位表をその場で読む。
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { dirname, join, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url))
9
+ const EFFORT = /×\s*(none|low|medium|high|xhigh|max|ultra)/iu
10
+ const vendorOf = (modelName) => {
11
+ const name = String(modelName).trim()
12
+ if (/^claude\b/iu.test(name)) return 'claude'
13
+ if (/^gpt\b|^gpt-/iu.test(name)) return 'codex'
14
+ if (/^grok\b/iu.test(name)) return 'grok'
15
+ return null
16
+ }
17
+
18
+ const fail = (code, message) => {
19
+ process.stderr.write(`${code}: ${message}\n`)
20
+ process.exit(2)
21
+ }
22
+
23
+ export function findModelsDoc({ env = process.env, exists = existsSync, scriptDir = here } = {}) {
24
+ if (env.PEERTABLE_MODELS_DOC) return resolve(env.PEERTABLE_MODELS_DOC)
25
+ if (env.DOTAGENTS_ROOT) return join(resolve(env.DOTAGENTS_ROOT), 'docs/02_models.md')
26
+ const sibling = resolve(scriptDir, '../../../dotagents/docs/02_models.md')
27
+ if (exists(sibling)) return sibling
28
+ return null
29
+ }
30
+
31
+ const stripCell = (cell) => String(cell ?? '').replace(/\*\*/g, '').trim()
32
+
33
+ const parseMarkdownTable = (block) => {
34
+ const lines = block.split('\n').map((line) => line.trim()).filter((line) => line.startsWith('|'))
35
+ if (lines.length < 3) return []
36
+ const headers = lines[0].split('|').slice(1, -1).map((cell) => stripCell(cell))
37
+ return lines.slice(2).map((line) => {
38
+ const cells = line.split('|').slice(1, -1).map((cell) => stripCell(cell))
39
+ return Object.fromEntries(headers.map((header, index) => [header, cells[index] ?? '']))
40
+ })
41
+ }
42
+
43
+ const section = (markdown, heading) => {
44
+ const start = markdown.indexOf(`## ${heading}`)
45
+ if (start < 0) return ''
46
+ const rest = markdown.slice(start)
47
+ const next = rest.search(/\n## /)
48
+ return next < 0 ? rest : rest.slice(0, next)
49
+ }
50
+
51
+ const slugOf = (cell) => {
52
+ const alias = cell.match(/alias\s+`([^`]+)`/u)
53
+ if (alias) return alias[1]
54
+ const slug = cell.match(/`([^`]+)`/u)
55
+ return slug ? slug[1] : null
56
+ }
57
+
58
+ export function parseLedger(markdown) {
59
+ const rows = parseMarkdownTable(section(markdown, 'モデル台帳'))
60
+ return rows.flatMap((row) => {
61
+ const name = row['モデル']
62
+ const slug = slugOf(row['slug'] ?? '')
63
+ const vendor = vendorOf(name)
64
+ if (!name || !slug || !vendor) return []
65
+ return [{ name, slug, vendor }]
66
+ })
67
+ }
68
+
69
+ const parseCell = (cell) => {
70
+ const raw = stripCell(cell)
71
+ if (!raw || raw === '—' || raw === '-') return null
72
+ if (raw.includes('オーナー指定')) return { skip: 'owner-pin', cell: raw }
73
+ if (/^ChatGPT/iu.test(raw) || raw.includes('gpt-connector')) return { skip: 'not-a-seat', cell: raw }
74
+ const effortMatch = raw.match(EFFORT)
75
+ const modelKey = raw.split('×')[0]
76
+ .replace(/([^)]*)/gu, '')
77
+ .replace(/\([^)]*\)/gu, '')
78
+ .trim()
79
+ if (!modelKey) return { skip: 'unparseable', cell: raw }
80
+ return {
81
+ modelKey,
82
+ effort: effortMatch ? effortMatch[1].toLowerCase() : null,
83
+ cell: raw,
84
+ }
85
+ }
86
+
87
+ const matchLedger = (modelKey, ledger) => {
88
+ const key = modelKey.toLowerCase()
89
+ const exact = ledger.find((row) => row.name.toLowerCase() === key)
90
+ if (exact) return exact
91
+ const contained = ledger.filter((row) => row.name.toLowerCase().includes(key) || key.includes(row.name.toLowerCase()))
92
+ if (contained.length === 1) return contained[0]
93
+ const token = ledger.filter((row) => row.name.toLowerCase().split(/\s+/u).includes(key))
94
+ if (token.length === 1) return token[0]
95
+ return null
96
+ }
97
+
98
+ export function resolveSeatPlacement(role, markdown, { source = '' } = {}) {
99
+ const wanted = String(role ?? '').trim()
100
+ if (!wanted) {
101
+ return { error: 'SEAT_ROLE_REQUIRED', message: 'role が空(02_models の役割名が要る)' }
102
+ }
103
+ const ranks = parseMarkdownTable(section(markdown, '順位表(役割→1位〜3位)'))
104
+ const row = ranks.find((item) => item['役割'] === wanted)
105
+ if (!row) {
106
+ const known = ranks.map((item) => item['役割']).filter(Boolean)
107
+ return { error: 'SEAT_ROLE_UNKNOWN', message: `未知の役割: ${wanted}(${known.join(' / ')})` }
108
+ }
109
+ const ledger = parseLedger(markdown)
110
+ const dropped = []
111
+ for (const rank of [1, 2, 3]) {
112
+ const parsed = parseCell(row[`${rank}位`])
113
+ if (!parsed) continue
114
+ if (parsed.skip) {
115
+ dropped.push({ rank, reason: parsed.skip, cell: parsed.cell })
116
+ continue
117
+ }
118
+ const hit = matchLedger(parsed.modelKey, ledger)
119
+ if (!hit) {
120
+ dropped.push({ rank, reason: 'not-in-ledger', cell: parsed.cell })
121
+ continue
122
+ }
123
+ if (hit.slug === 'haiku') {
124
+ return { role: wanted, rank, vendor: hit.vendor, model: hit.slug, effort: '', source, dropped }
125
+ }
126
+ if (!parsed.effort) {
127
+ dropped.push({ rank, reason: 'effort-missing', cell: parsed.cell })
128
+ continue
129
+ }
130
+ return {
131
+ role: wanted,
132
+ rank,
133
+ vendor: hit.vendor,
134
+ model: hit.slug,
135
+ effort: parsed.effort,
136
+ source,
137
+ dropped,
138
+ }
139
+ }
140
+ return {
141
+ error: 'SEAT_PLACEMENT_UNRESOLVABLE',
142
+ message: `${wanted} を着席可能な vendor/model/effort へ解決できない`,
143
+ dropped,
144
+ }
145
+ }
146
+
147
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
148
+ if (isMain) {
149
+ const role = process.argv[2]
150
+ const doc = findModelsDoc()
151
+ if (!doc) {
152
+ fail('SEAT_MODELS_DOC_MISSING', '02_models.md が見つからない(PEERTABLE_MODELS_DOC または DOTAGENTS_ROOT を渡す)')
153
+ }
154
+ if (!existsSync(doc)) {
155
+ fail('SEAT_MODELS_DOC_MISSING', `02_models.md が無い: ${doc}`)
156
+ }
157
+ const result = resolveSeatPlacement(role, readFileSync(doc, 'utf8'), { source: doc })
158
+ if (result.error) fail(result.error, result.message)
159
+ process.stdout.write(`${JSON.stringify(result)}\n`)
160
+ }
@@ -2,15 +2,29 @@
2
2
  // pane_pid から Lattice attach 用の pid / lstart / argv を1件に決める。
3
3
  // POSIX は ps。Windows は Win32_Process(ps -Ao は Git Bash で unknown option -- A)。
4
4
  import { execFileSync } from 'node:child_process'
5
-
6
- const panePid = process.argv[2]
7
- if (!/^\d+$/.test(panePid || '')) {
8
- console.error('usage: seat-identity.mjs <pane_pid>')
9
- process.exit(2)
10
- }
5
+ import { resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
11
7
 
12
8
  const AGENT = /(?:claude|codex|grok|composer)(?:\.cmd|\.exe)?(?:\s|$)/iu
13
9
 
10
+ export function parseWinCreationDate(value) {
11
+ if (value instanceof Date) {
12
+ if (Number.isNaN(value.getTime())) throw new Error('pid の CreationDate を解釈できない')
13
+ return value.toISOString()
14
+ }
15
+ if (typeof value === 'number' && Number.isFinite(value)) {
16
+ const parsed = new Date(value)
17
+ if (Number.isNaN(parsed.getTime())) throw new Error('pid の CreationDate を解釈できない')
18
+ return parsed.toISOString()
19
+ }
20
+ const text = String(value ?? '').trim()
21
+ const microsoft = /^\/Date\((-?\d+)(?:[+-]\d+)?\)\/$/u.exec(text)
22
+ if (microsoft) return new Date(Number(microsoft[1])).toISOString()
23
+ const parsed = new Date(text)
24
+ if (Number.isNaN(parsed.getTime())) throw new Error(`pid の CreationDate を解釈できない: ${text}`)
25
+ return parsed.toISOString()
26
+ }
27
+
14
28
  function posixIdentity(pid) {
15
29
  const table = execFileSync('ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8' })
16
30
  const leaders = table.split('\n').flatMap((line) => {
@@ -43,16 +57,24 @@ function winIdentity(pid) {
43
57
  if (AGENT.test(String(self.CommandLine || ''))) chosen = self
44
58
  else if (agentKids.length === 1) chosen = agentKids[0]
45
59
  else if (children.length === 1) chosen = children[0]
46
- const started = chosen.CreationDate ? new Date(chosen.CreationDate).toISOString() : ''
60
+ const started = chosen.CreationDate ? parseWinCreationDate(chosen.CreationDate) : ''
47
61
  const argv = String(chosen.CommandLine || '').trim()
48
62
  if (!started || !argv) throw new Error('pid の CreationDate/CommandLine を観測できない')
49
63
  return { pid: Number(chosen.ProcessId), started_identity: started, argv }
50
64
  }
51
65
 
52
- try {
53
- const ident = process.platform === 'win32' ? winIdentity(panePid) : posixIdentity(panePid)
54
- process.stdout.write(`${JSON.stringify(ident)}\n`)
55
- } catch (error) {
56
- console.error(error.message || error)
57
- process.exit(1)
66
+ const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
67
+ if (isCli) {
68
+ const panePid = process.argv[2]
69
+ if (!/^\d+$/.test(panePid || '')) {
70
+ console.error('usage: seat-identity.mjs <pane_pid>')
71
+ process.exit(2)
72
+ }
73
+ try {
74
+ const ident = process.platform === 'win32' ? winIdentity(panePid) : posixIdentity(panePid)
75
+ process.stdout.write(`${JSON.stringify(ident)}\n`)
76
+ } catch (error) {
77
+ console.error(error.message || error)
78
+ process.exit(1)
79
+ }
58
80
  }