peertable 0.3.7 → 0.3.9

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
@@ -75,6 +75,8 @@ docker compose -f deploy/compose.yaml up -d
75
75
 
76
76
  ライブビューはメンバーごとに vendor / model / effort と**稼働状態**(作業中・待機・**承認待ち**(許可ダイアログで止まっている)・停止)を出す。作業中の席はアイコンが動き、完了宣言(`[done]` / `[完了]` / `受理:` 等)の瞬間に席の上へ印が浮く。状態変化は SSE で押し込むので、30秒の再取得を待たず観測周期(約8秒)で切り替わる。発言にはログ番号(`[123]`)が付き、ライブ新着はブロック単位で現れる。**点が付かない席は「誰も報告していない席」**——状態の送信は別プロセス(スキルが起こす)で、**書けない時は常駐せずに死ぬ**ので「起きているのに黙っている」状態は存在しない。
77
77
 
78
+ 観測先は**席自身が名乗る**(`observe: {tmux_socket, tmux_target}`)。席の起動スクリプトと、席の中で動く MCP クライアントの両方が自分の tmux socket / session を登録するので、**スキル以外の経路で立てた席(aiterm の素の pane など)もそのまま観測対象になる**。表示名から `peer-<名前>` を推測しないので、任意のセッション名で立てた席が消える問題は起きない。名乗っていない古い席だけが従来の推測へ落ちる。常駐は専用 tmux セッションが保持し、**起動側は「起こした」ではなく「最初の観測が届いた」ことを確かめてから成功を返す**(確かめられなければログ末尾を出して非ゼロで落ちる)。
79
+
78
80
  API: `GET /api/<room>/messages` / `members` / `summary`(約120バイト・`seq`・`last_ts`・`member_count`)/ `events`(SSE)、`POST /api/<room>/messages` / `members`。
79
81
 
80
82
  **2. メンバーを着席させる。** room の MCP 定義は**プロジェクト root の `.mcp.json`** に置く:
package/README.md CHANGED
@@ -111,6 +111,8 @@ Open `http://localhost:8790` — every room gets a live web view (SSE). **The we
111
111
 
112
112
  The live view shows, per member: vendor / model / reasoning effort, and a **working state** — 作業中 (busy) · 待機 (idle) · 承認待ち (blocked on a permission prompt) · 停止 (dead). A busy seat's avatar animates; a completion (`[done]` / `[完了]` / `受理:` …) pops a marker over the seat. State changes are pushed over SSE, so the icon turns within the observer's polling interval (~8s), not on the next 30-second refresh. Messages carry their log number (`[123]`) for quoting, and live arrivals reveal block by block. **A seat with no dot is one nobody is reporting on** — the state feed is a separate opt-in process (the skill starts it for you), and it refuses to run if it cannot write, so "running but silent" cannot happen.
113
113
 
114
+ Seats **declare where to watch them** (`observe: {tmux_socket, tmux_target}`). Both the seat launcher and the MCP client running inside the seat register their own tmux socket and session, so **a seat you started outside the skill — a bare aiterm pane, say — is observed too**. Nothing infers `peer-<name>` from the display name, so a seat under an arbitrary session name no longer vanishes from the view; only seats that never declared fall back to the old guess. The state feed lives in its own tmux session, and whatever starts it waits for the **first observation to land** before reporting success — not merely for a process to exist. If it never lands, the starter prints the log tail and exits non-zero.
115
+
114
116
  Endpoints: `GET /api/<room>/messages` · `GET /api/<room>/members` · `GET /api/<room>/summary` (≈120 bytes: `seq`, `last_ts`, `member_count`) · `GET /api/<room>/events` (SSE) · `POST /api/<room>/messages` · `POST /api/<room>/members`.
115
117
 
116
118
  **2. Seat a member session.** The room MCP definition must live in the **project-root `.mcp.json`**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code sessions into a team of equal, long-lived peers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/room/client.mjs CHANGED
@@ -5,12 +5,13 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
6
6
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
+ import { execFileSync } from 'node:child_process'
8
9
  import { dirname, join } from 'node:path'
9
10
  import { fileURLToPath } from 'node:url'
10
11
 
11
12
  // client.mjs 側のハードコード版数。package.json の version と一致していることを
12
13
  // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
13
- const MCP_VERSION = '0.3.7'
14
+ const MCP_VERSION = '0.3.9'
14
15
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
15
16
 
16
17
  const USAGE = `usage:
@@ -121,10 +122,38 @@ await mcp.connect(new StdioServerTransport())
121
122
  // 素性(vendor/model/effort)は launch-seat.sh が env へ入れる。**登録のたびに載せる**——
122
123
  // 登録は client の起動ごとに繰り返し起きるので、1回きりの経路に置くと
123
124
  // member の状態が失われた時に二度と戻らない(server 側は渡された欄だけ更新する upsert)
125
+ function observeSelf() {
126
+ if (!process.env.TMUX) {
127
+ process.stderr.write('peertable-client: observe unavailable: TMUX 不在\n')
128
+ return null
129
+ }
130
+ if (!process.env.TMUX_PANE) {
131
+ process.stderr.write('peertable-client: observe unavailable: TMUX_PANE 不在\n')
132
+ return null
133
+ }
134
+ const socket = process.env.TMUX.split(',')[0]
135
+ if (!socket) {
136
+ process.stderr.write('peertable-client: observe unavailable: TMUX の socket が空\n')
137
+ return null
138
+ }
139
+ try {
140
+ const target = execFileSync('tmux', ['-S', socket, 'display-message', '-p', '-t', process.env.TMUX_PANE, '#S'], {
141
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
142
+ }).trim()
143
+ if (!target) throw new Error('session 名が空')
144
+ return { tmux_socket: socket, tmux_target: target }
145
+ } catch (error) {
146
+ process.stderr.write(`peertable-client: observe unavailable: ${error.message}\n`)
147
+ return null
148
+ }
149
+ }
150
+
151
+ const observe = observeSelf()
124
152
  const IDENTITY = Object.fromEntries(Object.entries({
125
153
  vendor: process.env.PEERTABLE_VENDOR,
126
154
  model: process.env.PEERTABLE_MODEL,
127
155
  effort: process.env.PEERTABLE_EFFORT,
156
+ observe,
128
157
  }).filter(([, v]) => v))
129
158
  await fetch(api('members'), { method: 'POST', headers, body: JSON.stringify({ name: ME, ...IDENTITY }) })
130
159
  {
package/skill/SKILL.md CHANGED
@@ -46,11 +46,12 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
46
46
  - 着任指示を第6引数に渡すと着席後に送る。文面: 「あなたは「<日本語名>」。.team/roles/member.md を読んで着任し、作業ループを開始せよ。全タスク完了の宣言まで自律的に続けること。」
47
47
  - 席が読む env は script が組み立てる(`PEERTABLE_URL` / `PEERTABLE_ROOM` / `PEERTABLE_MEMBER` / `PEERTABLE_POST_TOKEN`、Lattice 併用なら `PEERTABLE_PLAN` と actor 3点)。**channels は `--mcp-config` の MCP server を解決しない**(実測 2026-08-08・Claude Code v2.1.226・決定44)ため、room の MCP 定義は setup.sh が project root へ置く `.mcp.json` が正。project に既存 `.mcp.json` があった場合 setup.sh は上書きせず警告を出すので、AI が手動 merge して teardown で復元する
48
48
  - **Codex 席**(`vendor=codex`): Codex には channels が無いので、room は `-c` 上書きの stdio MCP として差す(`mcp_servers.room.command="peertable-client"` と `mcp_servers.room.env={…}`)。**env は closed mode で親環境を継がない**ので `PATH` を含む全変数を明示列挙する。effortはmember metadataだけでなく`model_reasoning_effort`へも同じ値を渡す。モデル名は ChatGPT アカウントで使える slug を渡す(`~/.codex/config.toml` の `model` が既定値の参考。使えない slug は起動後の最初のターンで 400 になって初めて分かる)。Codex 席を混ぜたら**必ず起床ブリッジを立てる**(下記)——立てないと room の新着で起きない
49
- 6. **起床ブリッジ(Codex 席がある時だけ)**: `nohup node scripts/wakeup-bridge.mjs <project> <codex席名>… > <project>/.team/wakeup-bridge.log 2>&1 &`。room の SSE を購読し、明示的にその席宛の新着(自分の発言は除く)だけを tmux へ素送信して起こす。**Codex はターン実行中でも素送信を受け付け、その文言をそのターンの中で読む**(実測)ので idle 待ちはしない。停止は `node scripts/wakeup-bridge.mjs <project> --stop`(teardown.sh が自動で行う)
49
+ 6. **起床ブリッジ(Codex 席がある時だけ)**: `scripts/ensure-bridge.sh <project> wakeup <codex席名>…`。room の SSE を購読し、明示的にその席宛の新着(自分の発言は除く)だけを tmux へ素送信して起こす。ensure は専用 tmux session で起動し、bridge の `ready_at` を待つ。確認できなければログ末尾を出して非ゼロで終わる。**Codex はターン実行中でも素送信を受け付け、その文言をそのターンの中で読む**(実測)ので idle 待ちはしない。停止は `node scripts/wakeup-bridge.mjs <project> --stop`(teardown.sh が自動で行う)
50
50
  - **黙って止まらないための三段**(決定58 の受信側の作法): ①75秒なにも届かなければ自分から切って繋ぎ直す ②繋ぎ直したら `?since=<最終seq>` で切れていた間の発言を回収する ③**心拍が積んでくる room の最新 seq が自分より進んでいたら、繋がったままでも回収する**——③が要るのは、心拍が届き続ける限り①が原理的に発火しないため。**server 側の心拍(`event: ping`・25秒周期)が前提**なので、古い room サーバーへ繋ぐと①だけが効く形になる
51
51
  - ログは `.team/wakeup-bridge.log`。**0件でも0件と出す**ので、起こせているか・取りこぼしていないかはログを見れば分かる。再現ハーネスは `experiments/bridge-catchup-repro.mjs`
52
52
 
53
- 6.5 **席の稼働状態ブリッジ(setup.sh が自動で起こす)**: 手順3の scaffold が `nohup node scripts/seat-status-bridge.mjs <project> > <project>/.team/seat-status-bridge.log 2>&1 &` を起こすので、**AI が手で叩く段は無い**(手で起こし直す時だけ同じ command を使う)。**room members に居る席だけ**の tmux pane を読み(`peer-<名前>`)、`busy`(画面に `esc to interrupt` が在る。**Claude 席・Codex 席の共通マーカーで、スピナーの語は毎回変わるので使わない**)/`blocked`(既知の承認ダイアログ文言が在る。判定順は busy blocked idle——承認プロンプト表示中は `esc to interrupt` が消えるため。動けない席を「待機」と誤表示しない)/`idle`/`dead`(`pane_dead`・セッション消失)を判定して room サーバーへ送る。**AI は使わず、席へは1バイトも送らない**。参加者一覧に点が出て、**報告が途絶えたら `unknown`(中空の輪)へ落ちる**——古い状態を出し続けない。**server が稼働状態を保持しない版なら1件も送らない**(送ると保存されないうえに system 発言を撒く)。停止は `node scripts/seat-status-bridge.mjs <project> --stop`(**teardown.sh が自動で行う**)
53
+ 6.5 **席の稼働状態ブリッジ(setup.sh が自動で起こす)**: 手順3の scaffold が `ensure-bridge.sh <project> seat-status` で起こすので、**AI が手で叩く段は無い**。観測先は member`observe: {tmux_socket, tmux_target}` を優先し、無い既存memberだけ `peer-<名前>` へ後方互換で落とす。socket は明示env aiterm POSIX既定検証済み`/tmp/aiterm-*.sock`1 既定パスの順で解決し、bashからは`tmux-socket.mjs`を呼ぶ。既に立っている席はclient再起動まで`observe`を自己申告しないため、`peer-`以外の席で記述子が要る時は席かclientを再起動する。WSLではbridgeが読む**WSL側**の版が正で、Windows側だけの更新では直らない。
54
+ - **`nohup` は使わない(決定74)。** `ensure-bridge.sh` が専用 tmux セッション `peertable-<bridge>-<room>` で常駐を保持する。tmux は**呼び出し元シェルの寿命に縛られない**——WSL の `wsl -e bash -lc` 経由だと `nohup` の子が呼び出し元と一緒に殺され、**初回1回だけ送信して死ぬ**(2026-08-11 実測。macOS では死なないので、この症状は WSL でしか出ない)。ensure は手渡された `PEERTABLE_TMUX_SOCKET` / `PEERTABLE_POST_TOKEN` / `PEERTABLE_URL` を command 側で明示して渡す——新 session が継ぐのは tmux *server* の環境で、呼び出し元 client の環境ではないため
54
55
  - **setup が起こし、teardown が止める(対称)。** 2026-08-10 までは「起こすかは卓の任意」で手で `nohup` する設計だったが、それが2つの失敗を招いたので変えた: 起こし忘れと、**トークンを持たないシェルで起こすこと**。後者は実害が出た——`~/.config/peertable.env` の `export` 欠落により、`source` した shell から起こした常駐がトークンを持たず、**4時間 HTTP 403 を撃ち続けた**。参加者一覧には点が1つも出ず、**「起こしていない」と見分けがつかなかった**
55
56
  - **書込トークンは起こす側の env に依存しない。** ブリッジ自身が `launch-seat.sh:25-27` と同じ規則で解決する(env が先・無ければ `~/.config/peertable.env` を読む。`export` の有無を問わない)。同じ規則を `run-bridge.mjs` も使う
56
57
  - **書けない常駐は生まれない。** 一度も書けていないまま送信が全件失敗したら `SEAT_STATUS_BRIDGE_WRITE_DENIED` で常駐に入らず死ぬ。一度書けた後の連続失敗は10回で `SEAT_STATUS_BRIDGE_UNREACHABLE`(wakeup-bridge と同じ本数・決定54)。**席がまだ立っていない tick は失敗に数えない**ので、setup 直後に起こしても死なない
@@ -98,7 +99,7 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
98
99
  - **席の作法の正本は `templates/member.md` の「Lattice の実行層へ自分の着手を載せる」節**(intake→intervention 判定→attach→作業→`todo done`→accept の順・1席1 intake・禁止操作・検証の回し方・成果の正本)。ここに二重化しない
99
100
  - **席は自分の pid を装置へ渡す(attach)。** その pid は `.team/seats/<名前>.json`(`launch-seat.sh` が着席直後に書く)が持ち、席は読んで `schema` を足すだけで attach input になる。**raw argv を保存しない**——Codex 起動の argv には `PEERTABLE_POST_TOKEN` が載るので、保存すれば秘密の複製になる(2026-08-09 実測)。持つのは digest だけ
100
101
  - **run-bridge は可視化の中継であって、必須経路ではない**(決定63)。落ちていても席は自分で intake / attach / accept を打てるし、介入も `lattice run intake intervention --run <ref> --task <id>` で自分で読める。**見えなくなるだけ**である
101
- - **ブリッジの起動**: `PEERTABLE_POST_TOKEN=… nohup node scripts/run-bridge.mjs <project> [--lattice <path>] > <project>/.team/run-bridge.log 2>&1 &`。停止は `node scripts/run-bridge.mjs <project> --stop`(**teardown.sh が自動で行う**)。**spool dir も席名も取らない**(配車をしないので要らない。渡すと typed error で落ちる)。ADR 0157 の作法(pid 記録・起動時に前の記録を掃除・SIGTERM→SIGKILL)はそのまま
102
+ - **ブリッジの起動**: `scripts/ensure-bridge.sh <project> run [--lattice <path>]`。停止は `node scripts/run-bridge.mjs <project> --stop`(**teardown.sh が自動で行う**)。**spool dir も席名も取らない**(配車をしないので要らない。渡すと typed error で落ちる)。ADR 0157 の作法(pid 記録・起動時に前の記録を掃除・SIGTERM→SIGKILL)はそのまま
102
103
  - `--lattice <path>` は release 前の source tree を実測する時に使う。**PATH の install は version 表示が同じでも未 publish の schema を読めない**(2026-08-09 に卓で3件実測)
103
104
  - bridge が返すのは2つだけで**どちらも読み取り**: `[run] <run-id> intake=[…] accepted=[…] hold=[…] closed=…`(変化時だけ)と、`hold` になった intake をその作業を始めた席宛へ返す `[介入]`
104
105
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  usage:
5
5
  archive-room-log.py <server_url> <room> <out.md> ログを書き出す
6
- archive-room-log.py --members <server_url> <room> member 名を空白区切りで出す(席を畳む相手)
6
+ archive-room-log.py --members [--json] <server_url> <room> member を出す(席を畳む相手)
7
7
 
8
8
  本文は投稿時の Markdown をそのまま置く(引用ブロックで包むと表とコードが崩れる)。
9
9
  本文の無い発言(過去に本文なし POST が着地した分)は、欠落と分かる形で残す——
@@ -21,9 +21,13 @@ def fetch(url, path):
21
21
 
22
22
  def main(argv):
23
23
  if argv[:1] == ["--members"]:
24
- url, room = argv[1], argv[2]
24
+ as_json = argv[1:2] == ["--json"]
25
+ url, room = argv[2:4] if as_json else argv[1:3]
25
26
  members = fetch(url, f"/api/{room}/members")["members"]
26
- print(" ".join(m["name"] for m in members))
27
+ if as_json:
28
+ print(json.dumps([{"name": m["name"], "observe": m.get("observe")} for m in members]))
29
+ else:
30
+ print(" ".join(m["name"] for m in members))
27
31
  return 0
28
32
 
29
33
  url, room, out = argv[0], argv[1], argv[2]
@@ -0,0 +1,55 @@
1
+ #!/bin/bash
2
+ # bridge を tmux に常駐させ、最初の ready_at まで待つ薄い supervisor。
3
+ set -euo pipefail
4
+
5
+ proj="$1"; name="$2"; shift 2
6
+ case "$name" in seat-status) script="seat-status-bridge.mjs" ;; wakeup) script="wakeup-bridge.mjs" ;; run) script="run-bridge.mjs" ;; *) echo "usage: ensure-bridge.sh <project> <seat-status|wakeup|run> [args...]" >&2; exit 1 ;; esac
7
+ team="$proj/.team"; record="$team/$name-bridge.json"; log="$team/$name-bridge.log"
8
+ force=false
9
+ if [ "${1:-}" = "--force" ]; then force=true; shift; fi
10
+ if [ $# -eq 0 ] && [ -f "$record" ]; then
11
+ saved=()
12
+ while IFS= read -r arg; do saved+=("$arg"); done < <(node -e 'const x=require(process.argv[1]); for (const a of x.args||[]) console.log(a)' "$record")
13
+ set -- "${saved[@]}"
14
+ fi
15
+ if [ -f "$record" ]; then
16
+ pid=$(node -e 'try{process.stdout.write(String(require(process.argv[1]).pid||""))}catch{}' "$record")
17
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then exit 0; fi
18
+ if ! "$force" && grep -q 'WRITE_DENIED' "$log" 2>/dev/null; then echo "${name}-bridge: 前回はWRITE_DENIEDで終了。--forceを指定すること" >&2; exit 1; fi
19
+ fi
20
+ sock=$(node "$(dirname "$0")/tmux-socket.mjs")
21
+ room=$(node -e 'process.stdout.write(require(process.argv[1]).room)' "$team/setup-state.json")
22
+ session="peertable-${name}-${room}"
23
+ # **死んだ記録をここで消す。** 残すと下の loop が前回の `ready_at` を読んで、
24
+ # 新しい bridge が1バイトも動いていない段階で success を返す——「起動していないのに
25
+ # 起動したと言う」=この supervisor が塞ぐはずの穴そのものになる。
26
+ rm -f "$record"
27
+ : > "$log"
28
+ # **手渡された env を常駐へ渡す。** 新しい session が継ぐのは tmux *server* の環境であって
29
+ # 呼び出し元 client の環境ではないので、素で起こすと `PEERTABLE_TMUX_SOCKET` の手渡しが黙って消え、
30
+ # 常駐が別の socket(本番の既定)を観測しにいく(2026-08-11 実測)。決定73 と同じ形の裏返しである。
31
+ env_prefix=""
32
+ for v in PEERTABLE_TMUX_SOCKET PEERTABLE_POST_TOKEN PEERTABLE_URL; do
33
+ eval "val=\${$v:-}"
34
+ [ -n "$val" ] && env_prefix="$env_prefix $v=$(printf '%q' "$val")"
35
+ done
36
+ # **session が在るだけでは常駐が生きている証拠にならない**(中の node だけ死んで殻が残る)。
37
+ # 上で pid 生存を確かめて here まで来た=生きていないので、殻は畳んでから立て直す。
38
+ tmux -S "$sock" kill-session -t "$session" 2>/dev/null || true
39
+ tmux -S "$sock" new-session -d -s "$session" "env${env_prefix} node $(dirname "$0")/$script $(printf '%q ' "$proj" "$@") >> $(printf '%q' "$log") 2>&1"
40
+ for _ in $(seq 1 30); do
41
+ if [ -f "$record" ] && node -e 'process.exit(require(process.argv[1]).ready_at?0:1)' "$record"; then
42
+ # **末尾は本物の改行にする。** `"\\n"` と書くと JS がリテラルの `\`+`n` を足し、
43
+ # record が JSON として壊れる——`--stop` も次回起動も `JSON.parse` で落ちて、
44
+ # 「止められない・建て直せない常駐」ができる(2026-08-11 実測)。一時 file→rename で原子的に。
45
+ node -e 'const fs=require("fs");const p=process.argv[1],a=process.argv.slice(2);
46
+ const t=`${p}.${process.pid}.tmp`;
47
+ fs.writeFileSync(t,JSON.stringify({...JSON.parse(fs.readFileSync(p,"utf8")),args:a})+"\n");
48
+ fs.renameSync(t,p)' "$record" "$@"
49
+ exit 0
50
+ fi
51
+ sleep .5
52
+ done
53
+ echo "${name}-bridge: ready_at を待てなかった。ログ末尾:" >&2
54
+ tail -n 20 "$log" >&2 || true
55
+ exit 1
@@ -20,7 +20,7 @@ if [ "$vendor" = "claude" ]; then
20
20
  esac
21
21
  fi
22
22
 
23
- sock="${PEERTABLE_TMUX_SOCKET:-${TMPDIR}claude-tmux-sockets/claude.sock}"
23
+ sock=$(node "$(dirname "$0")/tmux-socket.mjs")
24
24
  sess="peer-$name"
25
25
  state="$proj/.team/setup-state.json"
26
26
  read -r room url mode plan <<EOF
@@ -43,6 +43,10 @@ tmux -S "$sock" kill-session -t "$sess" 2>/dev/null || true
43
43
  # **消すのは同名の自分の分だけ**——`peer-*` を一括で消すと同じマシンの別卓を巻き込む。
44
44
  rm -f "$proj/.team/seats/$name.json"
45
45
  tmux -S "$sock" new-session -d -s "$sess" -x 200 -y 50 -c "$proj"
46
+ # Codex の closed env には launcher 自身でなく、今作った session の識別子を渡す。
47
+ # 自己申告の observe が別 pane を指すと、稼働状態・起床とも別席を誤操作する。
48
+ seat_tmux=$(tmux -S "$sock" display-message -p -t "$sess" '#{socket_path}')
49
+ seat_tmux_pane=$(tmux -S "$sock" display-message -p -t "$sess" '#{pane_id}')
46
50
 
47
51
  # 素性は席の env にも入れる。client が**登録のたびに**載せるので、member の状態が失われても戻る
48
52
  env_line="export PEERTABLE_URL=$url PEERTABLE_ROOM=$room PEERTABLE_MEMBER=$name PEERTABLE_POST_TOKEN=$PEERTABLE_POST_TOKEN"
@@ -65,7 +69,7 @@ case "$vendor" in
65
69
  # Codex には channels が無いので room は stdio MCP として差す。
66
70
  # `[mcp_servers.X.env]` は closed mode(親 env を継がない)ので全変数を明示列挙する
67
71
  # (caveat `codex-cli-v0-130-0-mcp-servers-x-env-block-is-closed-mode-parent-env-not-inherited`)。
68
- envtbl="PATH=\\\"$PATH\\\",PEERTABLE_URL=\\\"$url\\\",PEERTABLE_ROOM=\\\"$room\\\",PEERTABLE_MEMBER=\\\"$name\\\",PEERTABLE_POST_TOKEN=\\\"$PEERTABLE_POST_TOKEN\\\""
72
+ envtbl="PATH=\\\"$PATH\\\",PEERTABLE_URL=\\\"$url\\\",PEERTABLE_ROOM=\\\"$room\\\",PEERTABLE_MEMBER=\\\"$name\\\",PEERTABLE_POST_TOKEN=\\\"$PEERTABLE_POST_TOKEN\\\",TMUX=\\\"$seat_tmux\\\",TMUX_PANE=\\\"$seat_tmux_pane\\\""
69
73
  cmd="codex --model $model -C $proj --dangerously-bypass-approvals-and-sandbox"
70
74
  # Codexのeffortは環境変数だけでは適用されない。member metadataへ表示する値と、
71
75
  # 実際の推論設定を同じ引数から渡して食い違わせない。
@@ -188,10 +192,10 @@ fi
188
192
  # **欄が無い登録で既存の素性を消さない**(upsert)ことが前提である。
189
193
  # effort は渡された時だけ入れる——欄が無い=「不明」ではなく「CLI 既定で走っている」。
190
194
  # ここが失敗しても席は着席済みなので落とさない。ただし黙っては飲まない。
191
- meta=$(python3 - "$name" "$vendor" "$model" "$effort" <<'PY'
195
+ meta=$(python3 - "$name" "$vendor" "$model" "$effort" "$sock" "$sess" <<'PY'
192
196
  import json, sys
193
- name, vendor, model, effort = sys.argv[1:5]
194
- body = {'name': name, 'vendor': vendor, 'model': model}
197
+ name, vendor, model, effort, sock, sess = sys.argv[1:7]
198
+ body = {'name': name, 'vendor': vendor, 'model': model, 'observe': {'tmux_socket': sock, 'tmux_target': sess}}
195
199
  if effort:
196
200
  body['effort'] = effort
197
201
  print(json.dumps(body))
@@ -214,7 +218,7 @@ try:
214
218
  except Exception:
215
219
  members = []
216
220
  m = next((x for x in members if x.get('name') == sys.argv[1]), {})
217
- print('yes' if m.get('model') else 'no')
221
+ print('yes' if m.get('model') and m.get('observe', {}).get('tmux_target') else 'no')
218
222
  PY
219
223
  )
220
224
  if [ "$stored" = yes ]; then
@@ -226,6 +230,12 @@ else
226
230
  echo "metadata の登録に失敗した: 席は着席済みで、参加者一覧に素性が出ないだけ(room の到達性とトークンを確認)" >&2
227
231
  fi
228
232
 
233
+ if "$(dirname "$0")/ensure-bridge.sh" "$proj" seat-status; then
234
+ echo "seat-status-bridge: 起動確認済み"
235
+ else
236
+ echo "seat-status-bridge の起動確認に失敗した(席は着席済み)" >&2
237
+ fi
238
+
229
239
  if [ -n "$brief" ]; then
230
240
  sleep 2
231
241
  tmux -S "$sock" send-keys -t "$sess" "$brief"
@@ -21,7 +21,7 @@
21
21
  // 生死の作法は Lattice ADR 0157 に倣う: 自分の pid を記録に置き、起動時に前の記録を掃除し、
22
22
  // 止まらなければ黙って諦めず typed error で落ちる。
23
23
  import { execFile } from 'node:child_process'
24
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
24
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
25
25
  import { join } from 'node:path'
26
26
  import { promisify } from 'node:util'
27
27
 
@@ -166,12 +166,21 @@ const runSummaries = new Map() // run_ref -> 直近に記録した要約
166
166
  const interventions = new Map() // `${run_ref}\0${task_id}` -> 直近に観測した介入の形
167
167
  const closedRuns = new Set() // closed を観測した run。以後 poll しない
168
168
  let pollTicks = 0
169
+ let readyRecorded = false
170
+ function markReady() {
171
+ if (readyRecorded) return
172
+ const temp = `${record}.tmp`
173
+ writeFileSync(temp, JSON.stringify({ ...JSON.parse(readFileSync(record, 'utf8')), ready_at: new Date().toISOString() }) + '\n')
174
+ renameSync(temp, record)
175
+ readyRecorded = true
176
+ }
169
177
 
170
178
  // **どの run を見るかは `run list` から取る。** 旧版は order の worktree_path から run dir を
171
179
  // 切り出していたが、その order がもう出ないので、装置に聞く形へ変えた。
172
180
  // `selection: 'pull'` だけを対象にする——legacy automatic run は席が居ないので中継しても意味がない。
173
181
  async function pullRunRefs() {
174
182
  const listed = await lattice(['run', 'list', '--json'])
183
+ markReady()
175
184
  return (listed.active_runs ?? [])
176
185
  .filter(entry => entry.selection === 'pull' && typeof entry.run_ref === 'string')
177
186
  .map(entry => entry.run_ref)
@@ -21,10 +21,10 @@
21
21
  // 変わっていないのかを server が区別できない**(決定58 の liveness と cursor の分離と同じ形)。
22
22
  // server 側は最終受信からの経過で `unknown` へ落とす——古い状態を出し続けるのが最悪だから。
23
23
  import { execFileSync } from 'node:child_process'
24
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
24
+ import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs'
25
25
  import { join } from 'node:path'
26
26
 
27
- import { classifyPaneTail, decideBridgeContinuation, deriveMissingSession, parsePaneTokenHint, resolvePostToken, resolveTmuxSocket, supportsMemberObservation } from './seat-usage.mjs'
27
+ import { classifyPaneTail, decideBridgeContinuation, deriveMissingSession, parsePaneTokenHint, resolvePostToken, resolveSeatObservation, resolveTmuxSocket, supportsMemberObservation } from './seat-usage.mjs'
28
28
 
29
29
  const args = process.argv.slice(2)
30
30
  const proj = args[0]
@@ -76,28 +76,36 @@ const room = setup.room
76
76
  const token = resolvePostToken(process.env)
77
77
  writeFileSync(pidPath, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }) + '\n')
78
78
 
79
- // launch-seat.sh:14 が席を作るソケットと同じ解決規則(既定ソケットへ黙って fallback しない——
80
- // 見えないなら「見えない」と言う。同じルールを二重に書かない)
81
- const socket = resolveTmuxSocket(process.env)
82
- const tmux = (...a) => { try { return execFileSync('tmux', ['-S', socket, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) } catch { return null } }
79
+ // 記述子がある席は既定 socket を要らない。曖昧な既定 socket のせいで観測可能な席まで止めないよう、
80
+ // legacy 席を読む時だけ解決する。
81
+ let defaultSocketResolution = null
82
+ let resolutionLogged = false
83
+ let readyRecorded = false
84
+ const defaultSocket = () => {
85
+ if (defaultSocketResolution === null) defaultSocketResolution = resolveTmuxSocket(process.env)
86
+ return defaultSocketResolution.socket
87
+ }
88
+ const tmux = (socket, ...a) => { try { return execFileSync('tmux', ['-S', socket, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) } catch { return null } }
83
89
 
84
90
  // 監視するのは room の members に居る席だけ。tmux の `peer-*` を全部拾うと、同じマシンで走る別の卓を晒す
85
91
  async function seats() {
86
92
  const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
87
93
  const { members } = await res.json()
88
- return members.map(m => m.name)
94
+ return members
89
95
  }
90
96
 
91
- function readSeat(name, previous, observedAt) {
92
- const target = `peer-${name}`
93
- const dead = tmux('list-panes', '-t', target, '-F', '#{pane_dead}')
97
+ function readSeat(member, previous, observedAt) {
98
+ // まず記述子だけで解決する。socket が無い記述子/記述子なしの場合だけ既定を調べる。
99
+ const target = resolveSeatObservation(member, null) ?? resolveSeatObservation(member, defaultSocket())
100
+ if (target === null) return deriveMissingSession(previous)
101
+ const dead = tmux(target.socket, 'list-panes', '-t', target.target, '-F', '#{pane_dead}')
94
102
  // セッションが見つからない。tmux 席を持たない member(親など)は一度も観測できないので送らない
95
103
  // (`previous` が無い=一度も観測できていない)。過去に観測できていた席が消えたなら実際に落ちた
96
104
  if (dead === null) return deriveMissingSession(previous)
97
105
  if (dead.trim().split('\n')[0] === '1') {
98
106
  return { status: 'dead', busySince: null, paneTokenHint: null }
99
107
  }
100
- const pane = tmux('capture-pane', '-t', target, '-p')
108
+ const pane = tmux(target.socket, 'capture-pane', '-t', target.target, '-p')
101
109
  if (pane === null) return { status: 'dead', busySince: null, paneTokenHint: null }
102
110
  const tail = pane.split('\n').slice(-14).join('\n')
103
111
  const status = classifyPaneTail(tail)
@@ -138,9 +146,40 @@ const tokenBucket = value => value === null ? null : Math.floor(value / 1_000)
138
146
  // 数えないと、失敗を1行ずつ吐きながら永久に常駐する(2026-08-10 に4時間そうなった)
139
147
  const NOTHING_ATTEMPTED = { attempted: 0, failed: 0 }
140
148
 
149
+ function hasDescriptor(member) {
150
+ return member?.observe && typeof member.observe === 'object'
151
+ && typeof member.observe.tmux_target === 'string' && member.observe.tmux_target.length > 0
152
+ }
153
+
154
+ function logResolution(members) {
155
+ if (resolutionLogged) return
156
+ resolutionLogged = true
157
+ const descriptors = members.filter(hasDescriptor).length
158
+ const legacy = members.length - descriptors
159
+ if (legacy === 0) {
160
+ console.error(`seat-status-bridge: 観測記述子 ${descriptors} 席、legacy 0 席、既定 socket は未使用`)
161
+ return
162
+ }
163
+ const resolved = defaultSocketResolution ?? resolveTmuxSocket(process.env)
164
+ defaultSocketResolution = resolved
165
+ const detail = resolved.error
166
+ ? `${resolved.source} (${resolved.error.code}: ${resolved.candidates.join(', ')})`
167
+ : `${resolved.source} (${resolved.socket})`
168
+ console.error(`seat-status-bridge: 観測記述子 ${descriptors} 席、legacy ${legacy} 席、既定 socket=${detail}`)
169
+ }
170
+
171
+ function recordReady() {
172
+ if (readyRecorded) return
173
+ readyRecorded = true
174
+ const record = JSON.parse(readFileSync(pidPath, 'utf8'))
175
+ const temporary = `${pidPath}.${process.pid}.tmp`
176
+ writeFileSync(temporary, JSON.stringify({ ...record, ready_at: new Date().toISOString() }) + '\n')
177
+ renameSync(temporary, pidPath)
178
+ }
179
+
141
180
  async function tick() {
142
- let names
143
- try { names = await seats() } catch (e) { console.error(`seat-status-bridge: members を読めない: ${e.message}`); return NOTHING_ATTEMPTED }
181
+ let members
182
+ try { members = await seats() } catch (e) { console.error(`seat-status-bridge: members を読めない: ${e.message}`); return NOTHING_ATTEMPTED }
144
183
  // **送る前に、server が status を持つ版かを確かめる。**
145
184
  // 現行の `POST /members` は、既存メンバーでも `<名前> が参加した` を必ず room へ流す(`post()` が
146
185
  // `if (!members.has(name))` の外にある)。status を保持しない版へ投げると、**保存されないうえに
@@ -148,17 +187,20 @@ async function tick() {
148
187
  // 保持する版かどうかは GET で分かるので、**分かるまで投げない**。
149
188
  if (supported === null) {
150
189
  try { supported = await serverKeepsStatus() } catch { return NOTHING_ATTEMPTED } // 判定できない間は送らない
190
+ recordReady()
151
191
  if (!supported) console.error('seat-status-bridge: この room サーバーは稼働状態を保持しない版(GET /members に status が無い)。送信すると保存されないうえに system 発言を撒くので、送信しない')
152
192
  }
153
- if (!supported) { console.error(`seat-status-bridge: ${names.length} 席を見たが、server が未対応なので送っていない`); return NOTHING_ATTEMPTED }
193
+ logResolution(members)
194
+ if (!supported) { console.error(`seat-status-bridge: ${members.length} 席を見たが、server が未対応なので送っていない`); return NOTHING_ATTEMPTED }
154
195
  const now = Date.now()
155
196
  const observedAt = new Date(now).toISOString()
156
197
  let sent = 0
157
198
  let skipped = 0
158
199
  let failed = 0
159
- for (const name of names) {
200
+ for (const member of members) {
201
+ const { name } = member
160
202
  const prev = last.get(name)
161
- const observation = readSeat(name, prev, observedAt)
203
+ const observation = readSeat(member, prev, observedAt)
162
204
  // tmux 席を持たない member(親など)は一度も観測できないので送らない(deriveMissingSession が null を返す)
163
205
  if (observation === null) { skipped++; continue }
164
206
  const changed = !prev || prev.status !== observation.status
@@ -178,7 +220,7 @@ async function tick() {
178
220
  }
179
221
  }
180
222
  // 0件でも0件と言う(条件付きログにしない。沈黙する失敗を作らない・決定58)
181
- console.error(`seat-status-bridge: ${names.length} 席を見て ${sent} 件送った(tmux席を持たず観測対象外: ${skipped})`)
223
+ console.error(`seat-status-bridge: ${members.length} 席を見て ${sent} 件送った(tmux席を持たず観測対象外: ${skipped})`)
182
224
  return { attempted: sent + failed, failed }
183
225
  }
184
226
 
@@ -1,4 +1,5 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { readFileSync, readdirSync } from 'node:fs'
2
+ import { execFileSync } from 'node:child_process'
2
3
  import { homedir } from 'node:os'
3
4
  import { join } from 'node:path'
4
5
 
@@ -16,12 +17,68 @@ export function supportsMemberObservation(payload) {
16
17
  return payload?.capabilities?.member_observation_v1 === true
17
18
  }
18
19
 
20
+ /** aiterm-mcp の POSIX 既定ソケット規則。 */
21
+ export function defaultTmuxSocket(env) {
22
+ return join(env.TMPDIR || '/tmp', 'claude-tmux-sockets', 'claude.sock')
23
+ }
24
+
25
+ const defaultTmuxProbe = {
26
+ serverAlive(socket) {
27
+ try {
28
+ execFileSync('tmux', ['-S', socket, 'ls'], { stdio: 'ignore' })
29
+ return true
30
+ } catch {
31
+ return false
32
+ }
33
+ },
34
+ listAitermSockets() {
35
+ try {
36
+ return readdirSync('/tmp')
37
+ .filter(name => /^aiterm-.*\.sock$/u.test(name))
38
+ .map(name => join('/tmp', name))
39
+ } catch {
40
+ return []
41
+ }
42
+ },
43
+ }
44
+
19
45
  /**
20
- * launch-seat.sh:14 と同じ解決規則で aiterm-mcp の tmux ソケットを決める。
21
- * 規則を二重に書かない——片方だけ直すと同じ穴がもう一度開く。
46
+ * 明示設定、既定、検証済み発見の順で tmux socket を決める。
47
+ * 見つからない既定値は launch-seat.sh が新しい server を作るために必要なので返す。
22
48
  */
23
- export function resolveTmuxSocket(env) {
24
- return env.PEERTABLE_TMUX_SOCKET || `${env.TMPDIR}claude-tmux-sockets/claude.sock`
49
+ export function resolveTmuxSocket(env, probe = defaultTmuxProbe) {
50
+ if (env.PEERTABLE_TMUX_SOCKET) {
51
+ return { socket: env.PEERTABLE_TMUX_SOCKET, source: 'explicit', candidates: [], error: null }
52
+ }
53
+ const fallback = defaultTmuxSocket(env)
54
+ if (probe.serverAlive(fallback)) {
55
+ return { socket: fallback, source: 'default', candidates: [], error: null }
56
+ }
57
+ const candidates = probe.listAitermSockets().filter(socket => probe.serverAlive(socket))
58
+ if (candidates.length === 1) {
59
+ return { socket: candidates[0], source: 'discovered', candidates, error: null }
60
+ }
61
+ if (candidates.length > 1) {
62
+ return {
63
+ socket: null,
64
+ source: 'none',
65
+ candidates,
66
+ error: { code: 'PEERTABLE_TMUX_SOCKET_AMBIGUOUS', message: '検証済み aiterm socket が複数あります' },
67
+ }
68
+ }
69
+ return { socket: fallback, source: 'default', candidates: [], error: null }
70
+ }
71
+
72
+ /** member の自己申告を優先し、無い既存 member だけ旧 session 名へ互換フォールバックする。 */
73
+ export function resolveSeatObservation(member, defaultSocket) {
74
+ const observe = member?.observe
75
+ if (observe && typeof observe === 'object' && typeof observe.tmux_target === 'string' && observe.tmux_target) {
76
+ const socket = observe.tmux_socket || defaultSocket
77
+ return socket ? { socket, target: observe.tmux_target, source: 'descriptor' } : null
78
+ }
79
+ return defaultSocket && member?.name
80
+ ? { socket: defaultSocket, target: `peer-${member.name}`, source: 'legacy' }
81
+ : null
25
82
  }
26
83
 
27
84
  /**
@@ -169,7 +169,10 @@ printf '{"room":"%s","server_url":"%s","public_url":"%s","mode":"%s","plan_key":
169
169
  # 4時間 HTTP 403 を撃ち続け、参加者一覧には点が1つも出なかった=起こしていないのと見分けがつかない)。
170
170
  # AI は使わず席へも1バイト送らないので卓の作業を邪魔しない。**書けない時は常駐せずに死ぬ**ので、
171
171
  # ここで壊れた常駐が黙って残ることは無い。setup-state.json を読むので、必ずその後で起こす。
172
- nohup node "$repo/skill/scripts/seat-status-bridge.mjs" "$proj" > "$tdir/seat-status-bridge.log" 2>&1 &
173
- echo "seat-status-bridge: 起こした(ログ $tdir/seat-status-bridge.log・停止は teardown が行う)"
172
+ if "$repo/skill/scripts/ensure-bridge.sh" "$proj" seat-status; then
173
+ echo "seat-status-bridge: ready_at を確認した(ログ $tdir/seat-status-bridge.log・停止は teardown が行う)"
174
+ else
175
+ echo "seat-status-bridge: 起動を確認できなかった(ログ $tdir/seat-status-bridge.log)" >&2
176
+ fi
174
177
 
175
178
  echo "scaffold done: $tdir"
@@ -60,17 +60,23 @@ fi
60
60
 
61
61
  # ---- 席(tmux)の終了。**`.team/` を消す前**に、この room の member だけを畳む ----
62
62
  # `peer-*` を全部畳むと、同じマシンの別の卓を巻き込む(bridge が members 起点にしているのと同じ理由)
63
- sock="${PEERTABLE_TMUX_SOCKET:-${TMPDIR}claude-tmux-sockets/claude.sock}"
64
- seats=$(python3 "$(dirname "$0")/archive-room-log.py" --members "$url" "$room" 2>/dev/null || true)
65
- if [ -z "$seats" ]; then
63
+ sock=$(node "$(dirname "$0")/tmux-socket.mjs")
64
+ members_json=$(python3 "$(dirname "$0")/archive-room-log.py" --members --json "$url" "$room" 2>/dev/null || true)
65
+ seat_names=$(python3 "$(dirname "$0")/archive-room-log.py" --members "$url" "$room" 2>/dev/null || true)
66
+ member_lines=$(python3 -c 'import json,sys; [print(json.dumps(x,separators=(",",":"))) for x in json.loads(sys.stdin.read())]' <<<"$members_json" 2>/dev/null || true)
67
+ if [ -z "$members_json" ]; then
66
68
  miss "席の終了 — member 一覧が取れず、畳む相手を特定できない。手で確認: tmux -S \"$sock\" list-sessions | grep peer-"
67
69
  else
68
70
  closed=0
69
- for name in $seats; do
70
- if tmux -S "$sock" has-session -t "peer-$name" 2>/dev/null; then
71
- tmux -S "$sock" kill-session -t "peer-$name" && closed=$((closed + 1))
71
+ while IFS= read -r member; do
72
+ [ -n "$member" ] || continue
73
+ name=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["name"])' "$member")
74
+ target=$(python3 -c 'import json,sys; x=json.loads(sys.argv[1]); print((x.get("observe") or {}).get("tmux_target") or "peer-" + x["name"])' "$member")
75
+ member_sock=$(python3 -c 'import json,sys; x=json.loads(sys.argv[1]); print((x.get("observe") or {}).get("tmux_socket") or sys.argv[2])' "$member" "$sock")
76
+ if tmux -S "$member_sock" has-session -t "$target" 2>/dev/null; then
77
+ tmux -S "$member_sock" kill-session -t "$target" && closed=$((closed + 1))
72
78
  fi
73
- done
79
+ done <<<"$member_lines"
74
80
  if [ "$closed" -gt 0 ]; then did "席の終了(${closed}席)"; else skip "席の終了(生きている席なし)"; fi
75
81
  left=$(tmux -S "$sock" list-sessions 2>/dev/null | grep -c '^peer-' || true)
76
82
  [ "${left:-0}" -eq 0 ] || echo "teardown: [注記] 他の卓の peer-* が ${left}件 残っている(この卓のものではないので畳まない)"
@@ -242,12 +248,12 @@ if [ "$mode" = archive ]; then
242
248
  if [ -z "${PEERTABLE_POST_TOKEN:-}" ]; then
243
249
  miss "メンバー登録の解除 — TOKEN_MISSING: \`~/.config/peertable.env\` の定義が \`export\` 付きでないと子 process へ渡らない"
244
250
  echo "teardown: [手当] 参加者は次で外せる: curl -X DELETE \"$url/api/$room/members/<名前>\" -H \"X-Peertable-Token: \$PEERTABLE_POST_TOKEN\"" >&2
245
- elif [ -z "$seats" ]; then
251
+ elif [ -z "$seat_names" ]; then
246
252
  skip "メンバー登録の解除(一覧が取れていない)"
247
253
  else
248
254
  # 履歴に解散の区切りを残す。**部屋が続く以上、どこで卓が変わったかが読めないと
249
255
  # 過去ログが一続きの会話に見えてしまう**(次の campaign の発言と地続きになる)
250
- body="解散。この卓はここまで。参加者: ${seats}。部屋と過去ログはこのまま残り、次の卓も同じ部屋で続く。"
256
+ body="解散。この卓はここまで。参加者: ${seat_names}。部屋と過去ログはこのまま残り、次の卓も同じ部屋で続く。"
251
257
  python3 -c "
252
258
  import json,sys,urllib.request
253
259
  req=urllib.request.Request('$url/api/$room/messages', method='POST',
@@ -256,10 +262,12 @@ req=urllib.request.Request('$url/api/$room/messages', method='POST',
256
262
  urllib.request.urlopen(req, timeout=10).read()
257
263
  " 2>/dev/null && did "解散の区切りを履歴へ" || skip "解散の区切り(投稿できず・撤去は続行)"
258
264
  n=0
259
- for name in $seats; do
265
+ while IFS= read -r member; do
266
+ [ -n "$member" ] || continue
267
+ name=$(python3 -c 'import json,sys,urllib.parse; print(urllib.parse.quote(json.loads(sys.argv[1])["name"], safe=""))' "$member")
260
268
  c=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$url/api/$room/members/$name" -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" || true)
261
269
  [ "$c" = 200 ] && n=$((n + 1))
262
- done
270
+ done <<<"$member_lines"
263
271
  did "メンバー登録の解除(${n}名)— **部屋と過去ログは残す**($url/$room)"
264
272
  fi
265
273
  # room 削除は --purge だけ。トークンを要する唯一の段で、ここだけが外部サービスへの依存境界
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { resolveTmuxSocket } from './seat-usage.mjs'
3
+
4
+ const result = resolveTmuxSocket(process.env)
5
+ if (result.error?.code === 'PEERTABLE_TMUX_SOCKET_AMBIGUOUS') {
6
+ console.error(`${result.error.code}: ${result.candidates.join(', ')}`)
7
+ process.exit(1)
8
+ }
9
+ if (process.argv.includes('--source')) console.log(result.source)
10
+ else console.log(result.socket)
@@ -12,9 +12,10 @@
12
12
  // 送った文言はそのターンの中で読まれ、指示どおりに動いた(steering が効く)。したがって
13
13
  // idle 待ちの経路は持たない——待ちを入れると、混んでいる席ほど起床が遅れる。
14
14
  import { execFile } from 'node:child_process'
15
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
15
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
16
16
  import { join } from 'node:path'
17
17
  import { promisify } from 'node:util'
18
+ import { resolveSeatObservation, resolveTmuxSocket } from './seat-usage.mjs'
18
19
 
19
20
  const run = promisify(execFile)
20
21
  const [proj, ...rest] = process.argv.slice(2)
@@ -24,7 +25,8 @@ if (!proj || rest.length === 0) {
24
25
  }
25
26
 
26
27
  const record = join(proj, '.team', 'wakeup-bridge.json')
27
- const sock = process.env.PEERTABLE_TMUX_SOCKET ?? `${process.env.TMPDIR}claude-tmux-sockets/claude.sock`
28
+ const socketResult = resolveTmuxSocket(process.env)
29
+ const sock = socketResult.socket
28
30
  const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
29
31
  const sleep = ms => new Promise(r => setTimeout(r, ms))
30
32
  const log = line => console.log(`[${new Date().toISOString()}] ${line}`)
@@ -63,6 +65,28 @@ process.on('SIGINT', cleanup)
63
65
 
64
66
  // 席ごとに未配達を溜めて、2秒ごとにまとめて1回起こす(連投で席を何度も起こさない)
65
67
  const pending = new Map(seats.map(s => [s, []]))
68
+ let members = new Map()
69
+ async function refreshMembers() {
70
+ try {
71
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
72
+ if (!res.ok) throw new Error(`members ${res.status}`)
73
+ const body = await res.json()
74
+ members = new Map(body.members.map(member => [member.name, member]))
75
+ return true
76
+ } catch (error) {
77
+ // 記述子の更新が読めないことは起床停止の理由にしない。直前の map を保ち、
78
+ // 未登録席は wake() の legacy target へ戻す。縮退を必ずログに残す。
79
+ log(`member 記述子を更新できないので直前の観測先で続ける: ${error.message}`)
80
+ return false
81
+ }
82
+ }
83
+ await refreshMembers()
84
+ function markReady() {
85
+ const next = { ...JSON.parse(readFileSync(record, 'utf8')), ready_at: new Date().toISOString() }
86
+ const temp = `${record}.tmp`
87
+ writeFileSync(temp, JSON.stringify(next) + '\n')
88
+ renameSync(temp, record)
89
+ }
66
90
 
67
91
  async function wake(seat, msgs) {
68
92
  const last = msgs[msgs.length - 1]
@@ -71,9 +95,14 @@ async function wake(seat, msgs) {
71
95
  ? `room に新着あり(${last.from} → ${audience})。read_unread で読むこと。`
72
96
  : `room に新着 ${msgs.length} 件(最新: ${last.from} → ${audience})。read_unread で読むこと。`
73
97
  try {
74
- await run('tmux', ['-S', sock, 'send-keys', '-t', `peer-${seat}`, text])
98
+ // bridge 起動後の着席も次の配達で取り直す。初回だけの snapshot にすると
99
+ // 新席を「member 不明」として永久に起こせず、旧 peer- 互換より後退する。
100
+ await refreshMembers()
101
+ const observation = resolveSeatObservation(members.get(seat) ?? { name: seat }, sock)
102
+ if (observation === null) throw new Error(`観測記述子も既定 socket も無い: ${seat}`)
103
+ await run('tmux', ['-S', observation.socket, 'send-keys', '-t', observation.target, text])
75
104
  await sleep(400)
76
- await run('tmux', ['-S', sock, 'send-keys', '-t', `peer-${seat}`, 'Enter'])
105
+ await run('tmux', ['-S', observation.socket, 'send-keys', '-t', observation.target, 'Enter'])
77
106
  log(`起こした: ${seat} ← ${msgs.length} 件(最新 seq ${last.seq})`)
78
107
  } catch (error) {
79
108
  // 席が畳まれていれば tmux が落ちる。黙って飲まず、毎回出す(何件落としたかも出す)
@@ -172,6 +201,7 @@ for (;;) {
172
201
  if (!res.ok) throw new Error(`events ${res.status}`)
173
202
  failures = 0
174
203
  log('SSE 接続')
204
+ markReady()
175
205
  await catchUp('再接続')
176
206
  let buf = ''
177
207
  for await (const chunk of res.body) {