peertable 0.3.8 → 0.3.10

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.
@@ -1,15 +1,15 @@
1
1
  #!/bin/bash
2
2
  # 席を1つ立てる(tmux 作成 → env 注入 → エージェント起動 → 既知ダイアログ通過 → 着席確認)。
3
- # usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief]
3
+ # usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief] [role]
4
4
  # vendor: claude / codex
5
5
  # effort: 必須。既定値をコードへ埋めない——席を立てる時に決める(オーナー裁定)
6
6
  # brief: 着席が成立したら送る着任指示(省略時は送らない)
7
7
  #
8
8
  # room 名・server URL・モード・plan key は <project>/.team/setup-state.json から読む(setup.sh の後に呼ぶ)。
9
- # 書込トークンは環境変数 PEERTABLE_POST_TOKEN、無ければ ~/.config/peertable.env から取る。
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"
12
+ proj="$1"; name="$2"; model="$3"; vendor="$4"; effort="$5"; brief="$6"; role="${7:-worker}"
13
13
  [ -n "$proj" ] && [ -n "$name" ] && [ -n "$model" ] && [ -n "$vendor" ] && [ -n "$effort" ] || {
14
14
  echo "usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief]" >&2; exit 1;
15
15
  }
@@ -19,6 +19,195 @@ if [ "$vendor" = "claude" ]; then
19
19
  *) echo "unknown effort: ${effort}(claude は low|medium|high|xhigh|max)" >&2; exit 1 ;;
20
20
  esac
21
21
  fi
22
+ case "$role" in worker|auditor) ;; *) echo "unknown seat role: ${role}(worker / auditor)" >&2; exit 1 ;; esac
23
+
24
+ if [ -n "${PEERTABLE_MEMBER:-}" ]; then
25
+ echo "SEAT_LAUNCH_DELEGATED_CHILD_FORBIDDEN: PEERTABLE_MEMBER=${PEERTABLE_MEMBER} を継承した呼出元からの席起動を拒否" >&2
26
+ exit 1
27
+ fi
28
+
29
+ # 呼出元が古い手順でtokenをexportしていても、preflight・tmux・model CLIへ継承しない。
30
+ # 値の解決はcredential helperだけが設定fileから行う。env/argvへのfallbackは持たない。
31
+ unset PEERTABLE_POST_TOKEN
32
+ credential_helper="${PEERTABLE_CREDENTIAL_HELPER:-$(dirname "$0")/seat-credential.mjs}"
33
+ room_mcp_helper="${PEERTABLE_ROOM_MCP_HELPER:-$(dirname "$0")/ensure-room-mcp.mjs}"
34
+ codex_room_mcp_helper="${PEERTABLE_CODEX_ROOM_MCP_HELPER:-$(dirname "$0")/ensure-codex-room-mcp.mjs}"
35
+ aiterm_launch_helper="${PEERTABLE_AITERM_LAUNCH_HELPER:-$(dirname "$0")/aiterm-launch.mjs}"
36
+ peertable_repo=$(cd "$(dirname "$0")/../.." && pwd -P)
37
+ peertable_client="$peertable_repo/room/client.mjs"
38
+ credential_file=""
39
+ credential_persist=false
40
+ aiterm_session_id=""
41
+
42
+ # brief は tmux のコマンド引数へ直接載せない。長さを着席前に検証してから一時 file へ置き、
43
+ # tmux の buffer 経由で貼る。入力を受理できない時は model preflight・tmux・member 登録を
44
+ # 一つも行わず、何が拒否されたかを機械的に読める形で返す。
45
+ brief_file=""
46
+ brief_max_bytes=65536
47
+ brief_completed=false
48
+ brief_dispatched=false
49
+ seat_created=false
50
+ rollback_done=false
51
+ # ready を観測できないだけの不確実性は、投入後の真失敗と分ける。これは
52
+ # Aiterm から手動 dispatch できる空席として残し、席数へ成功着任とは数えない。
53
+ brief_not_ready=false
54
+ sock=""
55
+ sess=""
56
+ url=""
57
+ room=""
58
+ seat_file="$proj/.team/seats/$name.json"
59
+ cleanup_brief() {
60
+ if [ -n "$brief_file" ]; then rm -f "$brief_file"; fi
61
+ return 0
62
+ }
63
+ # brief を受け付けて送信した後に turn が始まらなかった場合は、作りかけの席を残さない。
64
+ # tmux を先に落とし、client が再登録しない状態にしてから room member を解除する。
65
+ # DELETE は idempotent だが、一覧の読み返しまで通らなければ rollback 成功とは言わない。
66
+ rollback_brief() {
67
+ local original_rc="${1:-1}"
68
+ local rollback_failed=0
69
+ local encoded_name member_code listing
70
+
71
+ if [ -n "$sock" ] && [ -n "$sess" ] && [ "$seat_created" = true ]; then
72
+ if tmux -S "$sock" has-session -t "$sess" 2>/dev/null; then
73
+ if ! tmux -S "$sock" kill-session -t "$sess" 2>/dev/null; then
74
+ rollback_failed=1
75
+ echo "LAUNCH_BRIEF_ROLLBACK_FAILED: tmux session を停止できない: ${sess}" >&2
76
+ elif tmux -S "$sock" has-session -t "$sess" 2>/dev/null; then
77
+ rollback_failed=1
78
+ echo "LAUNCH_BRIEF_ROLLBACK_FAILED: tmux session が停止後も残っている: ${sess}" >&2
79
+ fi
80
+ elif tmux -S "$sock" list-sessions >/dev/null 2>&1; then
81
+ : # serverへ到達でき、対象sessionが無い
82
+ else
83
+ rollback_failed=1
84
+ echo "LAUNCH_BRIEF_ROLLBACK_FAILED: tmux session を観測できない: ${sess}" >&2
85
+ fi
86
+ fi
87
+
88
+ if [ "$rollback_failed" -ne 0 ]; then
89
+ # live clientを止めたと確認できない時は、member/identity/credentialを先に消して
90
+ # rollback済みに見せない。credential cleanupはon_exitにもさせない。
91
+ credential_persist=true
92
+ return 1
93
+ fi
94
+
95
+ if [ -n "$seat_file" ] && [ -e "$seat_file" ]; then
96
+ if ! rm -f "$seat_file"; then
97
+ rollback_failed=1
98
+ echo "LAUNCH_BRIEF_ROLLBACK_FAILED: seat identity を撤去できない: ${seat_file}" >&2
99
+ fi
100
+ fi
101
+
102
+ if [ -n "$url" ] && [ -n "$room" ]; then
103
+ if ! encoded_name=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$name"); then
104
+ rollback_failed=1
105
+ echo "LAUNCH_BRIEF_ROLLBACK_FAILED: member 名を URL 化できない: ${name}" >&2
106
+ else
107
+ if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" DELETE \
108
+ "$url/api/$room/members/$encoded_name" >/dev/null; then
109
+ rollback_failed=1
110
+ echo "LAUNCH_BRIEF_ROLLBACK_FAILED: room member を解除できない" >&2
111
+ fi
112
+ listing=$(curl -sf "$url/api/$room/members" || true)
113
+ if ! printf '%s' "$listing" | python3 -c 'import json,sys; name=sys.argv[1]; members=json.load(sys.stdin).get("members",[]); raise SystemExit(0 if not any(m.get("name") == name for m in members) else 1)' "$name"; then
114
+ rollback_failed=1
115
+ echo "LAUNCH_BRIEF_ROLLBACK_FAILED: room member の解除を読み返せない: ${name}" >&2
116
+ fi
117
+ fi
118
+ else
119
+ rollback_failed=1
120
+ echo "LAUNCH_BRIEF_ROLLBACK_FAILED: room の rollback 境界が未解決" >&2
121
+ fi
122
+
123
+ if [ "$rollback_failed" -ne 0 ]; then
124
+ return 1
125
+ fi
126
+ echo "LAUNCH_BRIEF_ROLLED_BACK: ${sess}(tmux / room member / seat identity を撤去)" >&2
127
+ return "$original_rc"
128
+ }
129
+
130
+ on_exit() {
131
+ local exit_rc=$?
132
+ local rollback_rc
133
+ trap - EXIT
134
+ cleanup_brief
135
+ if [ "$seat_created" = true ] && [ "$brief_completed" != true ] && [ "$brief_not_ready" != true ] && [ "$rollback_done" != true ]; then
136
+ rollback_done=true
137
+ if rollback_brief "$exit_rc"; then
138
+ :
139
+ else
140
+ rollback_rc=$?
141
+ [ "$exit_rc" -eq 0 ] && exit_rc="$rollback_rc"
142
+ fi
143
+ fi
144
+ if [ -n "$credential_file" ] && [ "$credential_persist" != true ]; then
145
+ if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" remove "$proj" "$credential_file"; then
146
+ echo "SEAT_CREDENTIAL_ROLLBACK_FAILED: ${credential_file}" >&2
147
+ [ "$exit_rc" -eq 0 ] && exit_rc=1
148
+ fi
149
+ fi
150
+ exit "$exit_rc"
151
+ }
152
+ trap on_exit EXIT
153
+ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
154
+ brief_bytes=$(printf '%s' "$brief" | LC_ALL=C wc -c | tr -d '[:space:]')
155
+ case "$brief_bytes" in
156
+ ''|*[!0-9]*) echo "LAUNCH_BRIEF_INVALID: brief の byte 長を測定できない(席は立てない)" >&2; exit 2 ;;
157
+ esac
158
+ if [ "$brief_bytes" -gt "$brief_max_bytes" ]; then
159
+ echo "LAUNCH_BRIEF_TOO_LONG: brief が ${brief_bytes} bytes(上限 ${brief_max_bytes} bytes・席は立てない)" >&2
160
+ exit 2
161
+ fi
162
+ if ! brief_file=$(mktemp "${TMPDIR:-/tmp}/peertable-brief.XXXXXX"); then
163
+ echo "LAUNCH_BRIEF_PREPARE_FAILED: brief の輸送 file を作れない(席は立てない)" >&2
164
+ exit 2
165
+ fi
166
+ if ! printf '%s' "$brief" >"$brief_file"; then
167
+ echo "LAUNCH_BRIEF_PREPARE_FAILED: brief を輸送 file へ書けない(席は立てない)" >&2
168
+ exit 2
169
+ fi
170
+ fi
171
+
172
+ # **画面の文字列は「model が使えること」を意味しない。** 2026-08-11 実測: fable-5 の席は
173
+ # Claude の channels バナーが出たので下の着席判定を通ったが、その後の入力は全て
174
+ # model unavailable で 0 秒失敗し、席は一度も仕事をできなかった(room [11])。
175
+ # バナーは CLI が起動したことしか言わないので、**live に応答するかは非対話入口で先に測る**。
176
+ # 席を畳む前に測るのが要点である——ここで落ちれば、動いている席を殺さずに済む。
177
+ preflight_dir="${TMPDIR:-/tmp}"
178
+ case "$vendor" in
179
+ claude) preflight_cmd=(claude --model "$model" -p "ping") ;;
180
+ codex) preflight_cmd=(codex exec --model "$model" --skip-git-repo-check "ping") ;;
181
+ *) echo "unknown vendor: ${vendor}(claude / codex)" >&2; exit 1 ;;
182
+ esac
183
+ preflight_log=$(mktemp "${TMPDIR:-/tmp}/peertable-preflight.XXXXXX")
184
+ ( cd "$preflight_dir" && "${preflight_cmd[@]}" >"$preflight_log" 2>&1 </dev/null ) &
185
+ preflight_pid=$!
186
+ # 外部 CLI が黙って固まる場合に立卓ごと止めないための締切(外部境界なので上限を置く)。
187
+ preflight_deadline=$((SECONDS + 120))
188
+ preflight_rc=""
189
+ while [ $SECONDS -lt $preflight_deadline ]; do
190
+ if ! kill -0 "$preflight_pid" 2>/dev/null; then
191
+ # `set -e` の下では、失敗した子を素の `wait` で待つとそこで script ごと死ぬ。
192
+ # 条件式として使い、rc を自分で受ける(=失敗を握らず、メッセージを出して落とすため)
193
+ if wait "$preflight_pid"; then preflight_rc=0; else preflight_rc=$?; fi
194
+ break
195
+ fi
196
+ sleep 2
197
+ done
198
+ if [ -z "$preflight_rc" ]; then
199
+ kill "$preflight_pid" 2>/dev/null || true
200
+ echo "model preflight が 120 秒で返らない: ${vendor} / ${model}(席は立てない)" >&2
201
+ rm -f "$preflight_log"
202
+ exit 1
203
+ fi
204
+ if [ "$preflight_rc" != 0 ]; then
205
+ echo "model が live で使えない: ${vendor} / ${model}(preflight rc=${preflight_rc}・席は立てない)" >&2
206
+ tail -5 "$preflight_log" >&2
207
+ rm -f "$preflight_log"
208
+ exit 1
209
+ fi
210
+ rm -f "$preflight_log"
22
211
 
23
212
  sock=$(node "$(dirname "$0")/tmux-socket.mjs")
24
213
  sess="peer-$name"
@@ -30,95 +219,195 @@ EOF
30
219
  # (release 前の source tree では、PATH の install は pull 系 command を持たない)。
31
220
  # 無い卓(旧 setup-state)では空になり、席は既定どおり `lattice` を使う。
32
221
  lattice_cli=$(python3 -c "import json;print(json.load(open('$state')).get('lattice_cli') or '')")
222
+ if [ "$mode" = lattice ] && [ -z "$lattice_cli" ]; then
223
+ lattice_cli="${LATTICE_CLI:-lattice}"
224
+ fi
33
225
 
34
- if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; then
35
- . "$HOME/.config/peertable.env"
226
+ mcp_ownership=$(python3 -c "import json;d=json.load(open('$state'));print('managed' if d.get('added_root_mcp', d.get('root_mcp_json_fallback', False)) else 'preexisting')")
227
+ if ! node "$room_mcp_helper" "$proj" "$peertable_repo" "$mcp_ownership"; then
228
+ echo "SEAT_ROOM_MCP_INVALID: Aiterm席のroom clientをcurrent treeへ束縛できない(席は立てない)" >&2
229
+ exit 1
230
+ fi
231
+ if ! credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" prepare "$proj" "$room" "$name"); then
232
+ echo "SEAT_CREDENTIAL_PREPARE_FAILED: 席別credentialを用意できない(席は立てない)" >&2
233
+ exit 1
36
234
  fi
37
235
 
38
- # 前の卓の残骸を回収してから立てる(同名セッションが残ると起動が黙って古い席に化ける)
39
- tmux -S "$sock" kill-session -t "$sess" 2>/dev/null || true
236
+ # 同名の古いroom memberが残っていると、Codexの新しいroom clientが一度も登録していなくても
237
+ # 下のready確認を通ってしまう。古いmemberを別席のまま黙って消すのは危険なので、起動前に同名を
238
+ # conflictとして拒否し、明示的な退席後に再実行させる。一覧を読めない時も着席前に止める。
239
+ stale_members=$(curl -sf "$url/api/$room/members" || true)
240
+ stale_member_rc=0
241
+ python3 - "$name" "$stale_members" <<'PY' || stale_member_rc=$?
242
+ import json
243
+ import sys
244
+ try:
245
+ members = json.loads(sys.argv[2])['members']
246
+ except Exception:
247
+ raise SystemExit(2)
248
+ raise SystemExit(0 if any(member.get('name') == sys.argv[1] for member in members) else 1)
249
+ PY
250
+ case "$stale_member_rc" in
251
+ 1) : ;; # 同名memberなし。DELETEを発行せず、そのまま新席を起こす
252
+ 0)
253
+ echo "SEAT_ROOM_MEMBER_CONFLICT: 同名room memberが残っているため着席前に停止(明示的に退席してから再実行)" >&2
254
+ exit 1
255
+ ;;
256
+ *)
257
+ echo "SEAT_ROOM_MEMBER_STATE_UNREADABLE: room member一覧を読み取れない(席は立てない)" >&2
258
+ exit 1
259
+ ;;
260
+ esac
40
261
 
41
- # 素性記録(.team/seats/<name>.json)の掃除は**ここ**でやる。席を起こす経路は全席が必ず通るので、
42
- # 死んだ記録がここで必ず消える(ADR 0157)。teardown や人が叩くコマンドに置くと、誰も叩かず溜まる。
43
- # **消すのは同名の自分の分だけ**——`peer-*` を一括で消すと同じマシンの別卓を巻き込む。
262
+ # 前の卓の残骸を回収してから、Aiterm の公開 agent launcher を唯一の席起動経路として呼ぶ。
263
+ # direct CLI launch へ戻るfallbackは置かない。Aiterm が作る同名PTYと launch receipt が、
264
+ # 以後の `pty_read` / `agent_configure` / room metadata を同じsessionへ束縛する。
265
+ tmux -S "$sock" kill-session -t "$sess" 2>/dev/null || true
44
266
  rm -f "$proj/.team/seats/$name.json"
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}')
50
267
 
51
- # 素性は席の env にも入れる。client が**登録のたびに**載せるので、member の状態が失われても戻る
52
- env_line="export PEERTABLE_URL=$url PEERTABLE_ROOM=$room PEERTABLE_MEMBER=$name PEERTABLE_POST_TOKEN=$PEERTABLE_POST_TOKEN"
53
- env_line="$env_line PEERTABLE_VENDOR=$vendor PEERTABLE_MODEL=$model"
54
- [ -n "$effort" ] && env_line="$env_line PEERTABLE_EFFORT=$effort"
268
+ launch_env=(
269
+ "PEERTABLE_URL=$url"
270
+ "PEERTABLE_ROOM=$room"
271
+ "PEERTABLE_MEMBER=$name"
272
+ "PEERTABLE_CREDENTIAL_FILE=$credential_file"
273
+ "PEERTABLE_VENDOR=$vendor"
274
+ "PEERTABLE_MODEL=$model"
275
+ "PEERTABLE_EFFORT=$effort"
276
+ "PEERTABLE_ROLE=$role"
277
+ "PEERTABLE_TMUX_SOCKET=$sock"
278
+ )
55
279
  if [ "$mode" = "lattice" ]; then
56
- env_line="$env_line PEERTABLE_PLAN=$plan LATTICE_TODO_ACTOR_HOST=${LATTICE_TODO_ACTOR_HOST:-mac} LATTICE_TODO_ACTOR_SESSION=$name LATTICE_TODO_ACTOR_AGENT=$name"
57
- [ -n "$lattice_cli" ] && env_line="$env_line LATTICE_CLI=$lattice_cli"
280
+ launch_env+=(
281
+ "PEERTABLE_PLAN=$plan"
282
+ "LATTICE_TODO_ACTOR_HOST=${LATTICE_TODO_ACTOR_HOST:-mac}"
283
+ "LATTICE_TODO_ACTOR_SESSION=$name"
284
+ "LATTICE_TODO_ACTOR_AGENT=$name"
285
+ )
286
+ [ -n "$lattice_cli" ] && launch_env+=("LATTICE_CLI=$lattice_cli")
287
+ fi
288
+ if [ "$vendor" = codex ] \
289
+ && ! env -u PEERTABLE_POST_TOKEN "${launch_env[@]}" node "$codex_room_mcp_helper" ensure "$proj" "$peertable_repo"; then
290
+ echo "SEAT_CODEX_ROOM_MCP_INVALID: Codexのproject設定へseat固有room clientを装備できない(席は立てない)" >&2
291
+ exit 1
292
+ fi
293
+ # Aiterm launcherはsession作成後にtyped failureを返すことがある。ここから先は同名memberが
294
+ # 無いことを確認済みなので、launch試行が作ったtmux/memberをon_exitの対象として先に所有する。
295
+ seat_created=true
296
+ launch_receipt=$(env -u PEERTABLE_POST_TOKEN "${launch_env[@]}" node "$aiterm_launch_helper" "$sess" "$vendor" "$model" "$effort" "$proj" "$brief") || {
297
+ echo "SEAT_AITERM_LAUNCH_FAILED: ${name} をAiterm公開launcherで起動できない(direct CLI fallbackなし)" >&2
298
+ exit 1
299
+ }
300
+ if ! python3 - "$launch_receipt" "$sess" <<'PY'
301
+ import json, sys
302
+ try:
303
+ receipt = json.loads(sys.argv[1])
304
+ except Exception:
305
+ raise SystemExit(1)
306
+ raise SystemExit(0 if receipt.get('schema') == 'aiterm.agent-launch-result.v1' and receipt.get('session_id') == sys.argv[2] else 1)
307
+ PY
308
+ then
309
+ echo "SEAT_AITERM_LAUNCH_RECEIPT_INVALID: ${name} のsession_idを確定できない" >&2
310
+ exit 1
58
311
  fi
59
- tmux -S "$sock" send-keys -t "$sess" "$env_line" Enter
60
- sleep 1
312
+ aiterm_session_id="$sess"
313
+ brief_dispatched=true
314
+ seat_tmux=$(tmux -S "$sock" display-message -p -t "$sess" '#{socket_path}')
61
315
 
62
- case "$vendor" in
63
- claude)
64
- cmd="claude --model $model"
65
- [ -n "$effort" ] && cmd="$cmd --effort $effort"
66
- cmd="$cmd --dangerously-skip-permissions --dangerously-load-development-channels server:room"
67
- ;;
68
- codex)
69
- # Codex には channels が無いので room は stdio MCP として差す。
70
- # `[mcp_servers.X.env]` は closed mode(親 env を継がない)ので全変数を明示列挙する
71
- # (caveat `codex-cli-v0-130-0-mcp-servers-x-env-block-is-closed-mode-parent-env-not-inherited`)。
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\\\""
73
- cmd="codex --model $model -C $proj --dangerously-bypass-approvals-and-sandbox"
74
- # Codexのeffortは環境変数だけでは適用されない。member metadataへ表示する値と、
75
- # 実際の推論設定を同じ引数から渡して食い違わせない。
76
- [ -n "$effort" ] && cmd="$cmd -c 'model_reasoning_effort=\"$effort\"'"
77
- cmd="$cmd -c 'mcp_servers.room.command=\"peertable-client\"'"
78
- cmd="$cmd -c \"mcp_servers.room.env={$envtbl}\""
79
- ;;
80
- # 変数展開の直後に全角括弧を置かない(bash が高位バイトを変数名の一部として食い、
81
- # 変数が空になって黙って情報が消える。2026-08-08 実測)。必ず ${var} で閉じる
82
- *) echo "unknown vendor: ${vendor}(claude / codex)" >&2; exit 1 ;;
83
- esac
84
- tmux -S "$sock" send-keys -t "$sess" "$cmd" Enter
85
-
86
- # 既知ダイアログ(実測 2026-08-08・Claude Code v2.1.226 / Codex CLI v0.146.0):
87
- # claude ① 未信頼ディレクトリの workspace trust「1. Yes, I trust this folder」
88
- # ② 開発 channel 警告「1. I am using this for local development」
89
- # codex ① ディレクトリ trust「1. Yes, continue」
90
- # いずれも既定の選択肢が正なので、文言を確認してから Enter を送る。信頼済みディレクトリでは
91
- # trust が出ないので、出た時だけ通す(順不同・出ないものは待たない)。
92
- # 着席の判定は claude=channels バナー / codex=セッションヘッダ。出なければ画面ごと出して落ちる。
93
- deadline=$((SECONDS + 90))
94
- seated=false
95
- while [ $SECONDS -lt $deadline ]; do
96
- screen=$(tmux -S "$sock" capture-pane -t "$sess" -p)
97
- if [ "$vendor" = "claude" ]; then
98
- case "$screen" in *"Channels (experimental)"*"server:room"*) seated=true; break ;; esac
99
- else
100
- case "$screen" in *"OpenAI Codex (v"*) seated=true; break ;; esac
101
- fi
102
- case "$screen" in
103
- # Codex の更新案内だけは既定(1. Update now)が誤り——立卓の途中で
104
- # `npm install -g @openai/codex` が走る。1つ下の「2. Skip」を選ぶ
105
- *"1. Update now"*)
106
- tmux -S "$sock" send-keys -t "$sess" Down
107
- sleep 1
108
- tmux -S "$sock" send-keys -t "$sess" Enter ;;
109
- *"1. Yes, I trust this folder"*|*"1. I am using this for local development"*|*"1. Yes, continue"*)
110
- tmux -S "$sock" send-keys -t "$sess" Enter ;;
111
- esac
112
- sleep 2
113
- done
316
+ # Aiterm管理席の process 起動は公開launch receiptで確定している。旧direct CLI launch向けの
317
+ # ヘッダ/trust dialog待機をここへ重ねると、brief turnでヘッダが画面外へ流れた正常席をrollbackする。
318
+ # 必須room MCPの成立は、次の member登録readbackだけで判定する。
319
+ echo "launched: ${sess}(${vendor} / ${model}${effort:+ / $effort} / room=${room} / mode=${mode})"
114
320
 
115
- if [ "$seated" != "true" ]; then
116
- echo "着席しなかった: ${sess}(最後の画面を出す)" >&2
117
- tmux -S "$sock" capture-pane -t "$sess" -p >&2
321
+ # CodexのヘッダはCLIが起動した証拠であって、必須room MCPが初期化された証拠ではない。
322
+ # 無関係MCPのwarningが画面へ出ても、room clientがmember登録まで到達した席だけを着席として扱う。
323
+ # 登録が無ければ「着席済み」と丸めず、on_exitのrollbackへ渡す。
324
+ room_ready_deadline=$((SECONDS + 30))
325
+ room_ready=false
326
+ while [ $SECONDS -lt "$room_ready_deadline" ]; do
327
+ room_members=$(curl -sf "$url/api/$room/members" 2>/dev/null || true)
328
+ if printf '%s' "$room_members" | python3 -c 'import json,sys; name=sys.argv[1]; members=json.load(sys.stdin).get("members",[]); raise SystemExit(0 if any(m.get("name") == name for m in members) else 1)' "$name"; then
329
+ room_ready=true
330
+ break
331
+ fi
332
+ sleep 1
333
+ done
334
+ if [ "$room_ready" != true ]; then
335
+ echo "SEAT_ROOM_MCP_NOT_READY: room member登録を観測できない(無関係MCP warningとroom不成立を分離。席をrollbackする)" >&2
118
336
  exit 1
119
337
  fi
338
+ echo "room ready: ${room}/${name}"
339
+ if [ "$brief_dispatched" = true ]; then
340
+ brief_completed=true
341
+ echo "briefed: ${sess}(Aiterm launch prompt)"
342
+ fi
343
+
344
+ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
345
+ # Codex はヘッダを描いた後も MCP 初期化を続ける。Aiterm の ready 契約に合わせ、
346
+ # 同じ可視 pane 内に入力候補行とモデルフッタがある構造を連続して観測する。
347
+ # hook / MCP warning の更新で画面全体が変わっても、入力欄周辺が ready なら通す。
348
+ brief_ready_deadline=$((SECONDS + 90))
349
+ brief_ready_streak=0
350
+ brief_ready=false
351
+ while [ $SECONDS -lt "$brief_ready_deadline" ]; do
352
+ brief_ready_screen=$(tmux -S "$sock" capture-pane -t "$sess" -p 2>/dev/null || true)
353
+ if [ "$vendor" != codex ] || printf '%s\n' "$brief_ready_screen" | python3 -c 'import sys; lines=sys.stdin.read().splitlines()[-24:]; has_prompt=any(line.strip() == "›" or line.lstrip().startswith("› ") for line in lines); has_footer=any("gpt-" in line and "·" in line for line in lines); raise SystemExit(0 if has_prompt and has_footer else 1)'; then
354
+ brief_ready_streak=$((brief_ready_streak + 1))
355
+ if [ "$brief_ready_streak" -ge 3 ]; then
356
+ brief_ready=true
357
+ break
358
+ fi
359
+ else
360
+ brief_ready_streak=0
361
+ fi
362
+ sleep 1
363
+ done
364
+ if [ "$brief_ready" != true ]; then
365
+ brief_not_ready=true
366
+ echo "LAUNCH_BRIEF_NOT_READY: brief を受け付ける入力 prompt を観測できない(brief未投入・空席を保持。Aiterm手動dispatch対象)" >&2
367
+ else
368
+ # prompt の描画とキー入力受理の境界を分ける。Codex のTUIが入力欄を
369
+ # mountした直後に paste と Enter を同一tickで受けると、Enterだけ落ちる。
370
+ sleep 1
371
+
372
+ brief_before=$(tmux -S "$sock" capture-pane -S -1000 -t "$sess" -p 2>/dev/null || true)
373
+ brief_buffer="peertable-brief-${name}-$$"
374
+ if ! tmux -S "$sock" load-buffer -b "$brief_buffer" "$brief_file"; then
375
+ echo "LAUNCH_BRIEF_SEND_FAILED: brief の tmux buffer 読み込みに失敗(席は着席済み)" >&2
376
+ exit 1
377
+ fi
378
+ if ! tmux -S "$sock" paste-buffer -b "$brief_buffer" -d -t "$sess"; then
379
+ tmux -S "$sock" delete-buffer -b "$brief_buffer" 2>/dev/null || true
380
+ echo "LAUNCH_BRIEF_SEND_FAILED: brief の tmux paste に失敗(席は着席済み)" >&2
381
+ exit 1
382
+ fi
383
+ sleep 1
384
+ if ! tmux -S "$sock" send-keys -t "$sess" Enter; then
385
+ echo "LAUNCH_BRIEF_SEND_FAILED: brief の submit に失敗(席は着席済み)" >&2
386
+ exit 1
387
+ fi
120
388
 
121
- echo "seated: ${sess}(${vendor} / ${model}${effort:+ / $effort} / room=${room} / mode=${mode})"
389
+ # 入力欄へ置けた事実だけでは着任成功としない。既存の席状態判定と同じ live marker が、
390
+ # brief 投入後に画面へ現れたことを観測する。高速な fake/CLI の残像を拾わないよう、投入前の
391
+ # 画面と異なることも同時に要求する。dispatch は Aiterm の手動送信と同じく、この1回だけ行う。
392
+ brief_deadline=$((SECONDS + 30))
393
+ brief_turn_started=false
394
+ while [ $SECONDS -lt "$brief_deadline" ]; do
395
+ brief_screen=$(tmux -S "$sock" capture-pane -S -1000 -t "$sess" -p 2>/dev/null || true)
396
+ case "$brief_screen" in
397
+ *"esc to interrupt"*)
398
+ if [ "$brief_screen" != "$brief_before" ]; then brief_turn_started=true; break; fi
399
+ ;;
400
+ esac
401
+ sleep 1
402
+ done
403
+ if [ "$brief_turn_started" != true ]; then
404
+ echo "LAUNCH_BRIEF_TURN_NOT_STARTED: brief 投入後の turn 開始を観測できない(席は着席済み)" >&2
405
+ exit 1
406
+ fi
407
+ brief_completed=true
408
+ echo "briefed: $sess"
409
+ fi
410
+ fi
122
411
 
123
412
  # 席の素性を `.team/seats/<name>.json` へ置く。**席が自分の pid を知るための唯一の経路**である
124
413
  # (Lattice の `run intake attach` は expected identity を要求し、pid を推定しない)。
@@ -126,8 +415,7 @@ echo "seated: ${sess}(${vendor} / ${model}${effort:+ / $effort} / room=${room}
126
415
  #
127
416
  # 持たせるのは6欄だけで、**`lattice.pull_worker_attach_input.v1` の exact 集合から `schema` を
128
417
  # 除いたもの**と一致する。席は読んで `schema` を被せるだけで attach input になる(変換不要)。
129
- # **raw argv を持たせない**——Codex 起動の argv には `PEERTABLE_POST_TOKEN` が載るので、
130
- # 保存すれば秘密の複製になる(2026-08-09 実測)。digest だけを持つ。
418
+ # **raw argv を持たせない**——秘密値はargvから除いたが、将来の引数も含めて複製しない。digest だけを持つ。
131
419
  # この file が主張するのは「この pid はこの席だった」という**識別**であって、生死ではない。
132
420
  # 生きているかは attach する側(Lattice)が lstart+argv の再観測で確かめる。
133
421
  seat_pid=""
@@ -192,17 +480,18 @@ fi
192
480
  # **欄が無い登録で既存の素性を消さない**(upsert)ことが前提である。
193
481
  # effort は渡された時だけ入れる——欄が無い=「不明」ではなく「CLI 既定で走っている」。
194
482
  # ここが失敗しても席は着席済みなので落とさない。ただし黙っては飲まない。
195
- meta=$(python3 - "$name" "$vendor" "$model" "$effort" "$sock" "$sess" <<'PY'
483
+ meta=$(python3 - "$name" "$vendor" "$model" "$effort" "$sock" "$sess" "$role" "$aiterm_session_id" <<'PY'
196
484
  import json, sys
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}}
485
+ name, vendor, model, effort, sock, sess, role, aiterm_session_id = sys.argv[1:9]
486
+ body = {'name': name, 'vendor': vendor, 'model': model, 'role': role, 'aiterm_session_id': aiterm_session_id,
487
+ 'observe': {'tmux_socket': sock, 'tmux_target': sess}}
199
488
  if effort:
200
489
  body['effort'] = effort
201
490
  print(json.dumps(body))
202
491
  PY
203
492
  )
204
- if curl -sf -o /dev/null -X POST "$url/api/$room/members" \
205
- -H "X-Peertable-Token: ${PEERTABLE_POST_TOKEN:-}" -H 'content-type: application/json' -d "$meta"; then
493
+ if env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" POST \
494
+ "$url/api/$room/members" "$meta" >/dev/null; then
206
495
  # **200 は保存の証拠にならない**。素性欄を知らない server も 200 {"ok":true} を返して黙って捨てる
207
496
  # (登録が `if (!members.has(name))` の no-op になる経路もある)。読み返して実際に載ったかを見る。
208
497
  # **パイプと heredoc を同じ stdin へ重ねない**。`curl | python3 - <<'PY'` は
@@ -218,7 +507,7 @@ try:
218
507
  except Exception:
219
508
  members = []
220
509
  m = next((x for x in members if x.get('name') == sys.argv[1]), {})
221
- print('yes' if m.get('model') and m.get('observe', {}).get('tmux_target') else 'no')
510
+ print('yes' if m.get('model') and m.get('role') and m.get('aiterm_session_id') and m.get('observe', {}).get('tmux_target') else 'no')
222
511
  PY
223
512
  )
224
513
  if [ "$stored" = yes ]; then
@@ -230,16 +519,25 @@ else
230
519
  echo "metadata の登録に失敗した: 席は着席済みで、参加者一覧に素性が出ないだけ(room の到達性とトークンを確認)" >&2
231
520
  fi
232
521
 
233
- if "$(dirname "$0")/ensure-bridge.sh" "$proj" seat-status; then
522
+ if PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" seat-status; then
234
523
  echo "seat-status-bridge: 起動確認済み"
235
524
  else
236
525
  echo "seat-status-bridge の起動確認に失敗した(席は着席済み)" >&2
237
526
  fi
238
527
 
239
- if [ -n "$brief" ]; then
240
- sleep 2
241
- tmux -S "$sock" send-keys -t "$sess" "$brief"
242
- sleep 1
243
- tmux -S "$sock" send-keys -t "$sess" Enter
244
- echo "briefed: $sess"
528
+ if [ "$vendor" = codex ]; then
529
+ if PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" wakeup; then
530
+ echo "wakeup-bridge: 起動確認済み"
531
+ else
532
+ echo "SEAT_WAKEUP_BRIDGE_NOT_READY: Codex席の起床bridgeを準備できない" >&2
533
+ exit 1
534
+ fi
535
+ fi
536
+
537
+ if [ -z "$brief" ]; then brief_completed=true; fi
538
+ credential_persist=true
539
+ if [ "$brief_not_ready" = true ]; then
540
+ # 空席の後段セットアップ(identity / metadata / bridge)まで済ませたうえで、
541
+ # 呼び出し側にはready未確認を非0で返す。席はAiterm手動dispatchへ引き継ぐ。
542
+ exit 1
245
543
  fi
@@ -0,0 +1,90 @@
1
+ #!/bin/bash
2
+ # 1席だけを通常退席させ、runtime credentialまで同じ境界で撤去する。
3
+ # usage: leave-seat.sh <project_dir> <member>
4
+ set -u
5
+
6
+ unset PEERTABLE_POST_TOKEN
7
+ proj="${1:-}"
8
+ name="${2:-}"
9
+ [ -n "$proj" ] && [ -n "$name" ] || {
10
+ echo "SEAT_LEAVE_ARGS_INVALID: leave-seat.sh <project_dir> <member>" >&2
11
+ exit 2
12
+ }
13
+ case "$name" in
14
+ *[!A-Za-z0-9._:-]*) echo "SEAT_LEAVE_ARGS_INVALID: member名に使えない文字がある" >&2; exit 2 ;;
15
+ esac
16
+
17
+ script_dir=$(cd "$(dirname "$0")" && pwd -P)
18
+ credential_helper="${PEERTABLE_CREDENTIAL_HELPER:-$script_dir/seat-credential.mjs}"
19
+ state="$proj/.team/setup-state.json"
20
+ [ -f "$state" ] || { echo "SEAT_LEAVE_STATE_MISSING: $state" >&2; exit 1; }
21
+ read -r room url <<EOF
22
+ $(python3 -c "import json;d=json.load(open('$state'));print(d['room'],d['server_url'])")
23
+ EOF
24
+
25
+ credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" path "$proj" "$room" "$name") || exit 1
26
+ # 旧席などでcredentialだけ欠けている時は、sessionを止める前に復元する。
27
+ # ここで失敗すれば席は生きたままなので、member登録だけ残る半退席を作らない。
28
+ if [ ! -r "$credential_file" ] || [ ! -s "$credential_file" ]; then
29
+ credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" prepare "$proj" "$room" "$name") || {
30
+ echo "SEAT_LEAVE_CREDENTIAL_PREPARE_FAILED: $name" >&2
31
+ exit 1
32
+ }
33
+ fi
34
+ default_socket=$(node "$script_dir/tmux-socket.mjs" 2>/dev/null || true)
35
+ members=$(curl -sf "$url/api/$room/members" 2>/dev/null || true)
36
+ read -r member_socket target <<EOF
37
+ $(python3 - "$name" "$default_socket" "$members" <<'PY'
38
+ import json, sys
39
+ name, default_socket, raw = sys.argv[1:4]
40
+ try:
41
+ member = next((x for x in json.loads(raw).get('members', []) if x.get('name') == name), {})
42
+ except Exception:
43
+ member = {}
44
+ observe = member.get('observe') or {}
45
+ print(observe.get('tmux_socket') or default_socket, observe.get('tmux_target') or f'peer-{name}')
46
+ PY
47
+ )
48
+ EOF
49
+
50
+ failed=0
51
+ if [ -z "$member_socket" ]; then
52
+ echo "SEAT_LEAVE_SESSION_UNREADABLE: tmux socketを解決できない" >&2
53
+ failed=1
54
+ elif tmux -S "$member_socket" has-session -t "$target" 2>/dev/null; then
55
+ if ! tmux -S "$member_socket" kill-session -t "$target"; then
56
+ echo "SEAT_LEAVE_SESSION_FAILED: $target" >&2
57
+ failed=1
58
+ elif tmux -S "$member_socket" has-session -t "$target" 2>/dev/null; then
59
+ echo "SEAT_LEAVE_SESSION_FAILED: $target が撤去後も残っている" >&2
60
+ failed=1
61
+ fi
62
+ elif tmux -S "$member_socket" list-sessions >/dev/null 2>&1; then
63
+ : # serverへ到達でき、対象sessionが無い
64
+ else
65
+ echo "SEAT_LEAVE_SESSION_UNREADABLE: $member_socket" >&2
66
+ failed=1
67
+ fi
68
+
69
+ # live clientを止めたと確認できない時は、member/identity/credentialを先に消して
70
+ # 「退席済み」に見せない。観測面を残したままtyped failureで止める。
71
+ [ "$failed" -eq 0 ] || exit 1
72
+
73
+ encoded_name=$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$name")
74
+ if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" DELETE \
75
+ "$url/api/$room/members/$encoded_name" >/dev/null; then
76
+ echo "SEAT_LEAVE_MEMBER_FAILED: $name" >&2
77
+ failed=1
78
+ fi
79
+
80
+ if ! rm -f "$proj/.team/seats/$name.json"; then
81
+ echo "SEAT_LEAVE_IDENTITY_FAILED: $name" >&2
82
+ failed=1
83
+ fi
84
+ if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" remove "$proj" "$credential_file"; then
85
+ echo "SEAT_LEAVE_CREDENTIAL_FAILED: $name" >&2
86
+ failed=1
87
+ fi
88
+
89
+ [ "$failed" -eq 0 ] || exit 1
90
+ echo "SEAT_LEAVE_OK: ${name}(session / room member / seat identity / credential を撤去)"