peertable 0.4.36 → 0.5.0

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.
@@ -0,0 +1,185 @@
1
+ #!/bin/bash
2
+ # 卓の健全性を機械判定して表示し、--repair 指定時だけ死んでいるブリッジを立て直す。
3
+ # usage: doctor.sh <project_dir> [--repair]
4
+ #
5
+ # 判定するのは5点だけ:
6
+ # 1. room サーバー到達性(GET /api/<room>/summary)
7
+ # 2. 台帳(room member 行)の素性・本人性の完全性
8
+ # 3. 各席の tmux セッション(peer-<name>)の存在と、席file の pid+lstart による本人性
9
+ # 4. 2ブリッジ(wakeup / seat-status)の record 生存と、record にある識別情報だけで
10
+ # 判定できる範囲の本人性・鮮度(判定できないものは「判定不能」と正直に出す。偽の生存判定を作らない)
11
+ # 5. Lattice 併用モード(mode=lattice)なら `lattice status --json` の state / active_runs
12
+ #
13
+ # 各行は OK / NG / REPAIRED / 判定不能 のいずれかで始まる。--repair は NG のブリッジだけ
14
+ # ensure-bridge.sh で立て直す。**席の再起動はしない**——席は人の判断が要るので、NG 表示に
15
+ # launch-seat.sh を促す一言を添えるだけに留める。
16
+ #
17
+ # 終了コード: 全 OK(判定不能を含む)= 0、NG が1件でもあれば 1(--repair で全部直れば 0)。
18
+ set -euo pipefail
19
+
20
+ proj="${1:-}"
21
+ repair=false
22
+ if [ "${2:-}" = "--repair" ]; then repair=true; fi
23
+ if [ -z "$proj" ]; then
24
+ echo "usage: doctor.sh <project_dir> [--repair]" >&2
25
+ exit 1
26
+ fi
27
+ proj=$(cd "$proj" && pwd)
28
+ script_dir=$(cd "$(dirname "$0")" && pwd)
29
+ setup="$proj/.team/setup-state.json"
30
+ if [ ! -f "$setup" ]; then
31
+ echo "NG .team/setup-state.json が無い(${proj} は peertable setup 済みか確認せよ)" >&2
32
+ exit 1
33
+ fi
34
+
35
+ DOCTOR_PROJ="$proj" DOCTOR_SCRIPT_DIR="$script_dir" DOCTOR_REPAIR="$repair" \
36
+ node --input-type=module <<'NODE'
37
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
38
+ import { execFileSync, spawnSync } from 'node:child_process'
39
+ import { join } from 'node:path'
40
+ import { pathToFileURL } from 'node:url'
41
+
42
+ const proj = process.env.DOCTOR_PROJ
43
+ const scriptDir = process.env.DOCTOR_SCRIPT_DIR
44
+ const repair = process.env.DOCTOR_REPAIR === 'true'
45
+ const team = join(proj, '.team')
46
+
47
+ const { bridgeRecordLive } = await import(pathToFileURL(join(scriptDir, 'bridge-record-live.mjs')))
48
+ const { observePidCommand } = await import(pathToFileURL(join(scriptDir, 'refresh-seat-identity.mjs')))
49
+ const { resolveTmuxSocket, tmuxArgv } = await import(pathToFileURL(join(scriptDir, 'seat-usage.mjs')))
50
+
51
+ let ng = 0
52
+ function line(level, text) {
53
+ console.log(`${level} ${text}`)
54
+ if (level === 'NG') ng = 1
55
+ }
56
+
57
+ const setup = JSON.parse(readFileSync(join(team, 'setup-state.json'), 'utf8'))
58
+ const { room, server_url: url, mode } = setup
59
+
60
+ // 1. room サーバー到達性
61
+ let summary = null
62
+ try {
63
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/summary`)
64
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
65
+ summary = await res.json()
66
+ line('OK', `room 到達: ${url} room=${room} seq=${summary.seq} member_count=${summary.member_count}`)
67
+ } catch (error) {
68
+ line('NG', `room 到達不可: ${url} room=${room}(${error.message})`)
69
+ }
70
+
71
+ // members は以降の突合・席チェックにも使う
72
+ let members = []
73
+ let membersOk = false
74
+ try {
75
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
76
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
77
+ const body = await res.json()
78
+ members = Array.isArray(body.members) ? body.members : []
79
+ membersOk = true
80
+ } catch (error) {
81
+ line('NG', `room members を取得できない(${error.message})`)
82
+ }
83
+
84
+ // 2. 台帳の完全性。tmux 席を持つ member(親以外)は本人性欄(pid 等)も台帳に載っている
85
+ // はず(席file は 2026-08-22 廃止・正本は member 行だけ)
86
+ function hasDescriptor(member) {
87
+ return Boolean(member?.observe && typeof member.observe === 'object'
88
+ && typeof member.observe.tmux_target === 'string' && member.observe.tmux_target.length > 0)
89
+ }
90
+ const seatRows = new Map()
91
+ if (membersOk) {
92
+ const seated = members.filter(hasDescriptor)
93
+ const missingIdentity = seated.filter(m => !Number.isSafeInteger(m.pid) || !m.started_identity || !m.argv_digest)
94
+ for (const m of seated) seatRows.set(m.name, m)
95
+ if (missingIdentity.length === 0) {
96
+ line('OK', `台帳の member 行に素性・本人性が揃っている(${seated.length} 席)`)
97
+ } else {
98
+ line('NG', `台帳の本人性欄が欠けている member: ${missingIdentity.map(m => m.name).join(',')}`)
99
+ }
100
+ }
101
+
102
+ // 3. 各席の tmux セッション(peer-<name>)の存在と、台帳の pid+lstart による本人性
103
+ for (const [name, seat] of seatRows) {
104
+ const member = seat
105
+ const socket = member?.observe?.tmux_socket || resolveTmuxSocket(process.env).socket
106
+ const target = member?.observe?.tmux_target || `peer-${name}`
107
+ let sessionExists = false
108
+ try {
109
+ execFileSync('tmux', tmuxArgv(['has-session', '-t', target], { socket }), { stdio: 'ignore' })
110
+ sessionExists = true
111
+ } catch { /* has-session は非ゼロ終了で無しを表す */ }
112
+ if (!sessionExists) {
113
+ line('NG', `席 ${name}: tmux セッション ${target} が無い(launch-seat.sh で立て直す)`)
114
+ continue
115
+ }
116
+ let identity = null
117
+ let identityError = null
118
+ try { identity = observePidCommand(seat.pid) } catch (error) { identityError = error }
119
+ if (identityError) {
120
+ line('NG', `席 ${name}: pid ${seat.pid} を観測できない(${identityError.message})(launch-seat.sh で立て直す)`)
121
+ continue
122
+ }
123
+ if (identity.started_identity !== seat.started_identity) {
124
+ line('NG', `席 ${name}: pid ${seat.pid} は再利用されている(lstart不一致・本人ではない)(launch-seat.sh で立て直す)`)
125
+ continue
126
+ }
127
+ line('OK', `席 ${name}: tmux ${target} 生存・pid ${seat.pid} 本人性確認`)
128
+ }
129
+
130
+ // 4. 2ブリッジ(run-bridge は 2026-08-22 退役)。record の形式が違うので判定できる範囲だけ判定する
131
+ function judgeWakeup() {
132
+ const path = join(team, 'wakeup-bridge.json')
133
+ if (!existsSync(path)) return { level: 'NG', text: 'wakeup-bridge: record が無い(起動していない)' }
134
+ const record = JSON.parse(readFileSync(path, 'utf8'))
135
+ if (bridgeRecordLive(record)) {
136
+ const age = Math.round((Date.now() - Date.parse(record.last_progress_at)) / 1000)
137
+ return { level: 'OK', text: `wakeup-bridge: 生存 pid=${record.pid} last_progress ${age}秒前` }
138
+ }
139
+ return { level: 'NG', text: `wakeup-bridge: pid=${record.pid} が死んでいるか last_progress_at が古い` }
140
+ }
141
+
142
+ function judgeSeatStatusBridge() {
143
+ const path = join(team, 'seat-status-bridge.json')
144
+ if (!existsSync(path)) return { level: 'NG', text: 'seat-status-bridge: record が無い(起動していない)' }
145
+ const record = JSON.parse(readFileSync(path, 'utf8'))
146
+ let alive = false
147
+ try { process.kill(record.pid, 0); alive = true } catch { /* 死んでいる */ }
148
+ if (!alive) return { level: 'NG', text: `seat-status-bridge: pid=${record.pid} が死んでいる` }
149
+ return { level: '判定不能', text: `seat-status-bridge: pid=${record.pid} は生存しているが record に lstart が無く本人性を確認できない` }
150
+ }
151
+
152
+ for (const [name, judge] of [['wakeup', judgeWakeup], ['seat-status', judgeSeatStatusBridge]]) {
153
+ let result = judge()
154
+ if (result.level === 'NG' && repair) {
155
+ const res = spawnSync(join(scriptDir, 'ensure-bridge.sh'), [proj, name], { encoding: 'utf8' })
156
+ if (res.status === 0) {
157
+ const after = judge()
158
+ if (after.level !== 'NG') { line('REPAIRED', `${name}-bridge: 立て直した(${after.text})`); continue }
159
+ line('NG', `${name}-bridge: 立て直したが依然として不健全(${after.text})`)
160
+ continue
161
+ }
162
+ const detail = (res.stderr || res.stdout || '').trim().split('\n').slice(-3).join(' / ')
163
+ line('NG', `${name}-bridge: 立て直しに失敗(${detail || `exit ${res.status}`})`)
164
+ continue
165
+ }
166
+ line(result.level, result.text)
167
+ }
168
+
169
+ // 5. Lattice 併用モードの工程正本
170
+ if (mode === 'lattice') {
171
+ const latticeCli = process.env.LATTICE_CLI || (typeof setup.lattice_cli === 'string' && setup.lattice_cli) || 'lattice'
172
+ try {
173
+ const command = process.platform === 'win32' && !/\.(cmd|bat|exe)$/i.test(latticeCli) && existsSync(`${latticeCli}.cmd`)
174
+ ? `${latticeCli}.cmd`
175
+ : latticeCli
176
+ const out = execFileSync(command, ['status', '--json'], { cwd: proj, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 })
177
+ const status = JSON.parse(out)
178
+ line('OK', `Lattice: state=${status.state} active_runs=${(status.active_runs ?? []).length}`)
179
+ } catch (error) {
180
+ line('NG', `Lattice status を取得できない(${String(error.message ?? error).split('\n')[0]})`)
181
+ }
182
+ }
183
+
184
+ process.exit(ng)
185
+ NODE
@@ -3,7 +3,7 @@
3
3
  set -euo pipefail
4
4
 
5
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
6
+ case "$name" in seat-status) script="seat-status-bridge.mjs" ;; wakeup) script="wakeup-bridge.mjs" ;; run) echo "ENSURE_BRIDGE_RETIRED: run-bridge は退役した(2026-08-22)。介入は席が自分の Lattice コマンド応答で受け取る" >&2; exit 1 ;; *) echo "usage: ensure-bridge.sh <project> <seat-status|wakeup> [args...]" >&2; exit 1 ;; esac
7
7
  team="$proj/.team"; record="$team/$name-bridge.json"; log="$team/$name-bridge.log"
8
8
  force=false
9
9
  if [ "${1:-}" = "--force" ]; then force=true; shift; fi
@@ -6,6 +6,7 @@ import {
6
6
  } from 'node:fs'
7
7
  import { dirname, join, resolve } from 'node:path'
8
8
  import { execFileSync } from 'node:child_process'
9
+ import { repairSplicedBegin } from './codex-seat-toml.mjs'
9
10
 
10
11
  const fail = (code, message) => {
11
12
  process.stderr.write(`${code}: ${message}\n`)
@@ -71,6 +72,8 @@ function expectedBlock(addedNewline) {
71
72
  if (process.env.PATH) explicitEnv.unshift(`PATH = ${JSON.stringify(process.env.PATH)}`)
72
73
  return [
73
74
  `# BEGIN PEERTABLE ROOM MCP added_newline=${addedNewline ? '1' : '0'}`,
75
+ 'approval_policy = "never"',
76
+ 'sandbox_mode = "danger-full-access"',
74
77
  '[mcp_servers.room]',
75
78
  'command = "node"',
76
79
  `args = [${JSON.stringify(client)}]`,
@@ -120,7 +123,7 @@ try {
120
123
  const missing = requiredSeatEnv.filter((name) => !process.env[name])
121
124
  if (missing.length > 0)
122
125
  fail('SEAT_CODEX_ROOM_MCP_ENV_MISSING', `seat環境が無い: ${missing.join(',')}`)
123
- const current = existsSync(configFile) ? readFileSync(configFile, 'utf8') : ''
126
+ const current = repairSplicedBegin(existsSync(configFile) ? readFileSync(configFile, 'utf8') : '')
124
127
  const range = markerRange(current)
125
128
  let next
126
129
  if (range) {
@@ -107,7 +107,7 @@ sock=""
107
107
  sess=""
108
108
  url=""
109
109
  room=""
110
- seat_file="$proj/.team/seats/$name.json"
110
+ # 席の本人性は room 台帳の member 行が持つ(席file廃止 2026-08-22)。rollback は member DELETE が兼ねる
111
111
  cleanup_brief() {
112
112
  if [ -n "$brief_file" ]; then rm -f "$brief_file"; fi
113
113
  return 0
@@ -144,13 +144,6 @@ rollback_brief() {
144
144
  return 1
145
145
  fi
146
146
 
147
- if [ -n "$seat_file" ] && [ -e "$seat_file" ]; then
148
- if ! rm -f "$seat_file"; then
149
- rollback_failed=1
150
- echo "LAUNCH_BRIEF_ROLLBACK_FAILED: seat identity を撤去できない: ${seat_file}" >&2
151
- fi
152
- fi
153
-
154
147
  if [ -n "$url" ] && [ -n "$room" ]; then
155
148
  if ! encoded_name=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$name"); then
156
149
  rollback_failed=1
@@ -305,11 +298,6 @@ if ! node "$room_mcp_helper" "$proj" "$peertable_repo" "$mcp_ownership"; then
305
298
  echo "SEAT_ROOM_MCP_INVALID: Aiterm席のroom clientをcurrent treeへ束縛できない(席は立てない)" >&2
306
299
  exit 1
307
300
  fi
308
- if ! credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" prepare "$proj" "$room" "$name"); then
309
- echo "SEAT_CREDENTIAL_PREPARE_FAILED: 席別credentialを用意できない(席は立てない)" >&2
310
- exit 1
311
- fi
312
-
313
301
  # 同名の古いroom memberが残っていると、Codexの新しいroom clientが一度も登録していなくても
314
302
  # 下のready確認を通ってしまう。古いmemberを別席のまま黙って消すのは危険なので、起動前に同名を
315
303
  # conflictとして拒否し、明示的な退席後に再実行させる。一覧を読めない時も着席前に止める。
@@ -339,6 +327,15 @@ case "$stale_member_rc" in
339
327
  ;;
340
328
  esac
341
329
 
330
+ # credential は同名掃除の**後**に用意する。leave-seat は member ごと credential file
331
+ # (path は project/room/member から決定的)を撤去するので、先に作ると同名再着席の
332
+ # 初回だけ「消えた credential を席へ渡して client が initialize 前に死ぬ」レースになる
333
+ # (実被弾 2026-08-22: さくら再着席の初回必敗・再走で成功、の正体)。
334
+ if ! credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" prepare "$proj" "$room" "$name"); then
335
+ echo "SEAT_CREDENTIAL_PREPARE_FAILED: 席別credentialを用意できない(席は立てない)" >&2
336
+ exit 1
337
+ fi
338
+
342
339
  # leave-seat が席専用 GROK_HOME を消す。同名再着席では preflight 後に空になるので、
343
340
  # live 起動の直前に auth と ui だけを書き直す(user booth MCP は載せない)。
344
341
  if [ "$vendor" = grok ]; then
@@ -356,7 +353,6 @@ fi
356
353
  # direct CLI launch へ戻るfallbackは置かない。Aiterm が作る同名PTYと launch receipt が、
357
354
  # 以後の `pty_read` / `agent_configure` / room metadata を同じsessionへ束縛する。
358
355
  tmux_at kill-session -t "$sess" 2>/dev/null || true
359
- rm -f "$proj/.team/seats/$name.json"
360
356
 
361
357
  launch_env=(
362
358
  "PEERTABLE_URL=$url"
@@ -420,7 +416,34 @@ then
420
416
  exit 1
421
417
  fi
422
418
  aiterm_session_id="$sess"
423
- brief_dispatched=true
419
+ # **launch が返った事実は brief 配達の証拠にならない。** aiterm は TUI が入力受付前
420
+ # (update/trust ダイアログ表示中など)だと prompt を送らず、receipt の event_cursor=null で
421
+ # それを申告する。読まずに配達済み扱いにすると席が白紙のまま「briefed」と報告される
422
+ # (実被弾 2026-08-22: さくら再着席。席は起動したが着任指示ゼロで放置)。
423
+ # event_cursor が数値 → turn 開始をaitermが確認済み。submit_residue=true → 本文は
424
+ # composer に残ったが Enter が落ちた=後段で Enter だけ打ち直す。null → 未送信=後段で貼る。
425
+ brief_in_composer=false
426
+ if [ -n "$brief" ]; then
427
+ brief_receipt_state=$(python3 - "$launch_receipt" <<'PY'
428
+ import json, sys
429
+ r = json.loads(sys.argv[1])
430
+ cursor = r.get('event_cursor')
431
+ residue = r.get('submit_residue')
432
+ print('dispatched' if cursor is not None and residue is not True
433
+ else 'residue' if residue is True else 'not_sent')
434
+ PY
435
+ )
436
+ case "$brief_receipt_state" in
437
+ dispatched) brief_dispatched=true ;;
438
+ residue)
439
+ brief_in_composer=true
440
+ echo "SEAT_BRIEF_SUBMIT_RESIDUE: brief は入力欄に残り submit が落ちた。着席確認後に submit し直す" >&2
441
+ ;;
442
+ *)
443
+ echo "SEAT_BRIEF_LAUNCH_PROMPT_NOT_SENT: aiterm は TUI 入力受付前で brief を送っていない。着席確認後に貼り直す" >&2
444
+ ;;
445
+ esac
446
+ fi
424
447
  seat_tmux=$(tmux_at display-message -p -t "$sess" '#{socket_path}')
425
448
 
426
449
  # Aiterm管理席の process 起動は公開launch receiptで確定している。旧direct CLI launch向けの
@@ -441,42 +464,46 @@ codex_hooks_accepted=false
441
464
  codex_update_accepted=false
442
465
  codex_trust_accepted=false
443
466
  claude_trust_accepted=false
467
+ codex_dialog_helper="$peertable_script_dir/codex-dialog.mjs"
468
+ pass_codex_pane() {
469
+ local json key
470
+ json=$(printf '%s' "$1" | node "$codex_dialog_helper" || true)
471
+ [ -n "$json" ] && [ "$json" != "null" ] || return 1
472
+ while IFS= read -r key; do
473
+ [ -n "$key" ] || continue
474
+ tmux_at send-keys -t "$sess" "$key" || return 1
475
+ done < <(python3 -c 'import json,sys
476
+ a=json.loads(sys.stdin.read() or "null")
477
+ print("\n".join(a.get("keys") or []) if isinstance(a, dict) else "")' <<<"$json")
478
+ return 0
479
+ }
480
+ codex_pane_blocks_ready() {
481
+ printf '%s' "$1" | node "$codex_dialog_helper" --ready-ok
482
+ }
444
483
  while [ $SECONDS -lt "$room_ready_deadline" ]; do
445
484
  # Grok Build は初めて開く作業treeで、room MCPを初期化する前にworkspace trustを尋ねる。
446
485
  # Peertableが正式に着席させるtreeなので、この既知文言だけを一度通す。未知の確認画面を
447
486
  # 汎用的に承認するfallbackにはしない。承認後のMCP初期化時間は改めて30秒確保する。
448
487
  if [ "$vendor" = codex ]; then
449
488
  codex_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
450
- if [ "$codex_update_accepted" != true ]; then
451
- case "$codex_screen" in
452
- *"Update now"*)
453
- if tmux_at send-keys -t "$sess" Down && tmux_at send-keys -t "$sess" Enter; then
454
- codex_update_accepted=true
455
- room_ready_deadline=$((SECONDS + 90))
456
- echo "codex update prompt: skipped"
457
- fi
458
- ;;
459
- esac
460
- fi
461
- if [ "$codex_hooks_accepted" != true ]; then
489
+ if pass_codex_pane "$codex_screen"; then
490
+ room_ready_deadline=$((SECONDS + 90))
462
491
  case "$codex_screen" in
492
+ *"Allow the room MCP server to run tool"*) echo "codex mcp allow: always allow" ;;
463
493
  *"Hooks need review"*)
464
- if tmux_at send-keys -t "$sess" Down && tmux_at send-keys -t "$sess" Enter; then
465
- codex_hooks_accepted=true
466
- room_ready_deadline=$((SECONDS + 90))
467
- echo "codex hooks prompt: trust all"
468
- fi
494
+ codex_hooks_accepted=true
495
+ echo "codex hooks prompt: trust all"
496
+ ;;
497
+ *"Update now"*)
498
+ codex_update_accepted=true
499
+ echo "codex update prompt: skipped"
469
500
  ;;
470
- esac
471
- fi
472
- if [ "$codex_trust_accepted" != true ]; then
473
- case "$codex_screen" in
474
501
  *"Yes, continue"*)
475
- if tmux_at send-keys -t "$sess" Enter; then
476
- codex_trust_accepted=true
477
- room_ready_deadline=$((SECONDS + 90))
478
- echo "codex directory trust: accepted"
479
- fi
502
+ codex_trust_accepted=true
503
+ echo "codex directory trust: accepted"
504
+ ;;
505
+ *"Would you like to run the following command?"*)
506
+ echo "codex command approval: don't ask again"
480
507
  ;;
481
508
  esac
482
509
  fi
@@ -529,6 +556,13 @@ while [ $SECONDS -lt "$room_ready_deadline" ]; do
529
556
  fi
530
557
  room_members=$(curl -sf "$url/api/$room/members" 2>/dev/null || true)
531
558
  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
559
+ if [ "$vendor" = codex ]; then
560
+ codex_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
561
+ if ! codex_pane_blocks_ready "$codex_screen"; then
562
+ sleep 1
563
+ continue
564
+ fi
565
+ fi
532
566
  room_ready=true
533
567
  break
534
568
  fi
@@ -541,6 +575,24 @@ if [ "$room_ready" != true ]; then
541
575
  exit 1
542
576
  fi
543
577
  echo "room ready: ${room}/${name}"
578
+ if [ "$vendor" = codex ]; then
579
+ # member 登録後の最初の tool 呼び出しで MCP Allow が出る。出ている間だけ通し、沈黙5秒で抜ける。
580
+ codex_post_ready_deadline=$((SECONDS + 90))
581
+ codex_post_ready_idle=0
582
+ while [ $SECONDS -lt "$codex_post_ready_deadline" ]; do
583
+ codex_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
584
+ if pass_codex_pane "$codex_screen"; then
585
+ case "$codex_screen" in
586
+ *"Allow the room MCP server to run tool"*) echo "codex mcp allow: always allow" ;;
587
+ esac
588
+ codex_post_ready_idle=0
589
+ else
590
+ codex_post_ready_idle=$((codex_post_ready_idle + 1))
591
+ [ "$codex_post_ready_idle" -ge 5 ] && break
592
+ fi
593
+ sleep 1
594
+ done
595
+ fi
544
596
  if [ "$brief_dispatched" = true ]; then
545
597
  brief_completed=true
546
598
  echo "briefed: ${sess}(Aiterm launch prompt)"
@@ -575,17 +627,20 @@ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
575
627
  sleep 1
576
628
 
577
629
  brief_before=$(tmux_at capture-pane -S -1000 -t "$sess" -p 2>/dev/null || true)
578
- brief_buffer="peertable-brief-${name}-$$"
579
- if ! tmux_at load-buffer -b "$brief_buffer" "$brief_file"; then
580
- echo "LAUNCH_BRIEF_SEND_FAILED: brief の tmux buffer 読み込みに失敗(席は着席済み)" >&2
581
- exit 1
582
- fi
583
- if ! tmux_at paste-buffer -b "$brief_buffer" -d -t "$sess"; then
584
- tmux_at delete-buffer -b "$brief_buffer" 2>/dev/null || true
585
- echo "LAUNCH_BRIEF_SEND_FAILED: brief tmux paste に失敗(席は着席済み)" >&2
586
- exit 1
630
+ # submit_residue の席は本文が composer に残っている。貼り直すと二重になるので Enter だけ打つ。
631
+ if [ "$brief_in_composer" != true ]; then
632
+ brief_buffer="peertable-brief-${name}-$$"
633
+ if ! tmux_at load-buffer -b "$brief_buffer" "$brief_file"; then
634
+ echo "LAUNCH_BRIEF_SEND_FAILED: brief の tmux buffer 読み込みに失敗(席は着席済み)" >&2
635
+ exit 1
636
+ fi
637
+ if ! tmux_at paste-buffer -b "$brief_buffer" -d -t "$sess"; then
638
+ tmux_at delete-buffer -b "$brief_buffer" 2>/dev/null || true
639
+ echo "LAUNCH_BRIEF_SEND_FAILED: brief の tmux paste に失敗(席は着席済み)" >&2
640
+ exit 1
641
+ fi
642
+ sleep 1
587
643
  fi
588
- sleep 1
589
644
  if ! tmux_at send-keys -t "$sess" Enter; then
590
645
  echo "LAUNCH_BRIEF_SEND_FAILED: brief の submit に失敗(席は着席済み)" >&2
591
646
  exit 1
@@ -614,15 +669,12 @@ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
614
669
  fi
615
670
  fi
616
671
 
617
- # 席の素性を `.team/seats/<name>.json` へ置く。**席が自分の pid を知るための唯一の経路**である
618
- # (Lattice `run intake attach` は expected identity を要求し、pid を推定しない)。
672
+ # 席の本人性(pid / 起動時刻 / argv digest)を room 台帳の member 行へ登録する。
673
+ # member に帰属する情報の正本は台帳だけ(オーナー裁定 2026-08-22・席fileは廃止)。
619
674
  # 着席の**後**に取る——起動途中の process を掴むと、ダイアログ通過で子が入れ替わりうる。
620
- #
621
- # 持たせるのは6欄だけで、**`lattice.pull_worker_attach_input.v1` exact 集合から `schema` を
622
- # 除いたもの**と一致する。席は読んで `schema` を被せるだけで attach input になる(変換不要)。
623
- # **raw argv を持たせない**——秘密値はargvから除いたが、将来の引数も含めて複製しない。digest だけを持つ。
624
- # この file が主張するのは「この pid はこの席だった」という**識別**であって、生死ではない。
625
- # 生きているかは attach する側(Lattice)が lstart+argv の再観測で確かめる。
675
+ # **raw argv を持たせない**——digest だけを持つ。この記録が主張するのは「この pid は
676
+ # この席だった」という**識別**であって、生死ではない。生きているかは attach する側
677
+ # (Lattice)が lstart+argv の再観測で確かめる。
626
678
  seat_pid=""
627
679
  pane_pid=$(tmux_at list-panes -t "$sess" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
628
680
  seat_ident=""
@@ -639,80 +691,45 @@ if [ -z "$seat_pid" ]; then
639
691
  # 記録が無ければ席は attach できず、装置の介入は協調 hold のままになる。**黙らない。**
640
692
  echo "seat identity を記録できなかった: ${sess} の process group leader を1つに確定できない(席は着席済み)" >&2
641
693
  else
642
- mkdir -p "$proj/.team/seats"
643
- # **`session` tmux 名(`peer-<name>`)を入れてはいけない。** Lattice の attach は
644
- # `input.session === actor.session` を要求し(`runtime-pull-intake.mjs:674`)、actor session は
645
- # 上の env_line が入れる `LATTICE_TODO_ACTOR_SESSION=$name` である。tmux 識別子を混ぜると
646
- # attach が必ず `WORKER_ACTOR_MISMATCH` で拒否される(mio の監査で発覚・room [937])。
647
- if ! python3 - "$proj/.team/seats/$name.json" "$name" "$name" "$seat_ident" <<'PY'
648
- import hashlib, json, os, subprocess, sys, tempfile
649
- out, name, session, ident_raw = sys.argv[1:5]
694
+ ident_body=$(python3 - "$name" "$seat_ident" <<'PY'
695
+ import json, subprocess, sys
696
+ name, ident_raw = sys.argv[1:3]
650
697
  ident = json.loads(ident_raw)
651
- pid = int(ident['pid'])
652
- started = ident['started_identity']
653
- argv = ident['argv']
654
- if not started or not argv:
698
+ if not ident.get('started_identity') or not ident.get('argv') or not ident.get('argv_digest'):
655
699
  sys.exit('pid の lstart/args を観測できない')
656
- record = {
657
- 'argv_digest': hashlib.sha256(argv.encode()).hexdigest(),
700
+ print(json.dumps({
658
701
  'name': name,
659
- 'pid': pid,
660
- 'recorded_at': subprocess.run(['date', '-u', '+%Y-%m-%dT%H:%M:%S.000Z'],
661
- capture_output=True, text=True, check=True).stdout.strip(),
662
- 'session': session,
663
- 'started_identity': started,
664
- }
665
- # canonical JSON(key 昇順・空白なし)+ 0600 + 一時file→fsync→rename で原子的に置く。
666
- # 着席直後に席が読むので、部分読取が起きない形にする。
667
- body = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n'
668
- fd, tmp = tempfile.mkstemp(dir=os.path.dirname(out), prefix='.seat-', suffix='.tmp')
669
- try:
670
- with os.fdopen(fd, 'w') as handle:
671
- handle.write(body)
672
- handle.flush()
673
- os.fsync(handle.fileno())
674
- os.chmod(tmp, 0o600)
675
- os.replace(tmp, out)
676
- except BaseException:
677
- os.unlink(tmp)
678
- raise
702
+ 'pid': int(ident['pid']),
703
+ 'started_identity': ident['started_identity'],
704
+ 'argv_digest': ident['argv_digest'],
705
+ 'identity_recorded_at': subprocess.run(['date', '-u', '+%Y-%m-%dT%H:%M:%S.000Z'],
706
+ capture_output=True, text=True, check=True).stdout.strip(),
707
+ }, ensure_ascii=False))
679
708
  PY
680
- then
681
- echo "seat identity を記録できなかった: ${sess}(席は着席済み・attach は協調 hold のままになる)" >&2
709
+ ) || ident_body=""
710
+ if [ -z "$ident_body" ] || ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" POST \
711
+ "$url/api/$room/members" "$ident_body" >/dev/null; then
712
+ echo "seat identity を台帳へ登録できなかった: ${sess}(席は着席済み・attach は協調 hold のままになる)" >&2
682
713
  fi
683
714
  fi
684
715
 
685
- # 席の素性(roles / settings / mission)を room へ渡す。一覧チップと席の名簿が同じ欄を見る。
686
- tmux_ns=$(node "$(dirname "$0")/tmux-socket.mjs" --namespace)
687
- # python stdout Windows cp932 になり 実装 が壊れる。Node が UTF-8 JSON を出す。
688
- meta=$(node "$(dirname "$0")/seat-metadata.mjs" "$name" "$vendor" "$model" "$effort" "$sock" "$sess" "$role" "$mission" "$aiterm_session_id" "$tmux_ns")
689
- if env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" POST \
690
- "$url/api/$room/members" "$meta" >/dev/null; then
691
- # **200 は保存の証拠にならない**。素性欄を知らない server も 200 {"ok":true} を返して黙って捨てる
692
- # (登録が `if (!members.has(name))` の no-op になる経路もある)。読み返して実際に載ったかを見る。
693
- # **パイプと heredoc を同じ stdin へ重ねない**。`curl | python3 - <<'PY'` は
694
- # 「プログラムを stdin から読む」と「データを stdin から読む」が衝突して、必ず失敗する
695
- # (そして try/except で包むと、失敗が「保存されていない」という**もっともらしい答え**に化ける。実測)
696
- listing=$(curl -sf "$url/api/$room/members" || true)
697
- stored=$(python3 - "$name" "$listing" <<'PY'
716
+ # 素性(vendor/model/roles/mission/observe/aiterm ID)の書き手は**席の room client だけ**。
717
+ # ランチャーはここで台帳を読み返し、実際に載ったかを確認するだけにする(重複書込の禁止)。
718
+ member_row=$(curl -sf "$url/api/$room/members/$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$name")" || true)
719
+ stored=$(python3 - "$member_row" <<'PY'
698
720
  import json, sys
699
- # 読み返しに失敗しても生の traceback を出さない。ここは「保存されたか」を見るだけの確認段で、
700
- # 判定不能は「保存されていない」と同じ扱いでよい(席は既に着席している)
701
721
  try:
702
- members = json.loads(sys.argv[2])['members']
722
+ m = json.loads(sys.argv[1])['member']
703
723
  except Exception:
704
- members = []
705
- m = next((x for x in members if x.get('name') == sys.argv[1]), {})
706
- print('yes' if m.get('model') and (m.get('roles') or m.get('role')) and m.get('aiterm_session_id') and m.get('observe', {}).get('tmux_target') else 'no')
724
+ m = {}
725
+ print('yes' if m.get('model') and m.get('roles') and m.get('aiterm_session_id')
726
+ and (m.get('observe') or {}).get('tmux_target') and m.get('pid') else 'no')
707
727
  PY
708
728
  )
709
- if [ "$stored" = yes ]; then
710
- echo "metadata: ${vendor} / ${model}${effort:+ / $effort}${role:+ / $role}"
711
- else
712
- echo "metadata は保存されなかった: この room サーバーは素性欄を持たない版(席は着席済み・一覧に素性が出ないだけ)" >&2
713
- fi
729
+ if [ "$stored" = yes ]; then
730
+ echo "台帳確認: ${vendor} / ${model}${effort:+ / $effort}${role:+ / $role}(素性・本人性とも登録済み)"
714
731
  else
715
- echo "metadata の登録に失敗した: 席は着席済みで、参加者一覧に素性が出ないだけ(room の到達性とトークンを確認)" >&2
732
+ echo "SEAT_LEDGER_INCOMPLETE: 台帳の member 行に素性または本人性が欠けている(席は着席済み。doctor で確認)" >&2
716
733
  fi
717
734
 
718
735
  if PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" seat-status; then
@@ -76,10 +76,7 @@ if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_
76
76
  failed=1
77
77
  fi
78
78
 
79
- if ! rm -f "$proj/.team/seats/$name.json"; then
80
- echo "SEAT_LEAVE_IDENTITY_FAILED: $name" >&2
81
- failed=1
82
- fi
79
+ # 席の本人性は台帳の member 行が持つ(席file廃止 2026-08-22)。上の member DELETE が identity も撤去する
83
80
  rm -rf "$proj/.team/seats/${name}.grok-home"
84
81
  rm -rf "$proj/.team/seats/${name}.codex"
85
82
  if ! env -u PEERTABLE_POST_TOKEN node "$credential_helper" remove "$proj" "$credential_file"; then