peertable 0.3.6 → 0.3.8

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,17 +1,26 @@
1
1
  #!/bin/bash
2
2
  # 席を1つ立てる(tmux 作成 → env 注入 → エージェント起動 → 既知ダイアログ通過 → 着席確認)。
3
- # usage: launch-seat.sh <project_dir> <name> <model> [vendor] [effort] [brief]
4
- # vendor: claude(既定)/ codex
3
+ # usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief]
4
+ # vendor: claude / codex
5
+ # effort: 必須。既定値をコードへ埋めない——席を立てる時に決める(オーナー裁定)
5
6
  # brief: 着席が成立したら送る着任指示(省略時は送らない)
6
7
  #
7
8
  # room 名・server URL・モード・plan key は <project>/.team/setup-state.json から読む(setup.sh の後に呼ぶ)。
8
9
  # 書込トークンは環境変数 PEERTABLE_POST_TOKEN、無ければ ~/.config/peertable.env から取る。
9
10
  # tmux は aiterm-mcp と同じソケットへ作るので、立てた席はそのまま pty_read / pty_send で読める。
10
11
  set -e
11
- proj="$1"; name="$2"; model="$3"; vendor="${4:-claude}"; effort="$5"; brief="$6"
12
- [ -n "$proj" ] && [ -n "$name" ] && [ -n "$model" ] || { echo "usage: launch-seat.sh <project_dir> <name> <model> [vendor] [effort] [brief]" >&2; exit 1; }
12
+ proj="$1"; name="$2"; model="$3"; vendor="$4"; effort="$5"; brief="$6"
13
+ [ -n "$proj" ] && [ -n "$name" ] && [ -n "$model" ] && [ -n "$vendor" ] && [ -n "$effort" ] || {
14
+ echo "usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief]" >&2; exit 1;
15
+ }
16
+ if [ "$vendor" = "claude" ]; then
17
+ case "$effort" in
18
+ low|medium|high|xhigh|max) ;;
19
+ *) echo "unknown effort: ${effort}(claude は low|medium|high|xhigh|max)" >&2; exit 1 ;;
20
+ esac
21
+ fi
13
22
 
14
- sock="${PEERTABLE_TMUX_SOCKET:-${TMPDIR}claude-tmux-sockets/claude.sock}"
23
+ sock=$(node "$(dirname "$0")/tmux-socket.mjs")
15
24
  sess="peer-$name"
16
25
  state="$proj/.team/setup-state.json"
17
26
  read -r room url mode plan <<EOF
@@ -34,6 +43,10 @@ tmux -S "$sock" kill-session -t "$sess" 2>/dev/null || true
34
43
  # **消すのは同名の自分の分だけ**——`peer-*` を一括で消すと同じマシンの別卓を巻き込む。
35
44
  rm -f "$proj/.team/seats/$name.json"
36
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}')
37
50
 
38
51
  # 素性は席の env にも入れる。client が**登録のたびに**載せるので、member の状態が失われても戻る
39
52
  env_line="export PEERTABLE_URL=$url PEERTABLE_ROOM=$room PEERTABLE_MEMBER=$name PEERTABLE_POST_TOKEN=$PEERTABLE_POST_TOKEN"
@@ -56,7 +69,7 @@ case "$vendor" in
56
69
  # Codex には channels が無いので room は stdio MCP として差す。
57
70
  # `[mcp_servers.X.env]` は closed mode(親 env を継がない)ので全変数を明示列挙する
58
71
  # (caveat `codex-cli-v0-130-0-mcp-servers-x-env-block-is-closed-mode-parent-env-not-inherited`)。
59
- 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\\\""
60
73
  cmd="codex --model $model -C $proj --dangerously-bypass-approvals-and-sandbox"
61
74
  # Codexのeffortは環境変数だけでは適用されない。member metadataへ表示する値と、
62
75
  # 実際の推論設定を同じ引数から渡して食い違わせない。
@@ -179,10 +192,10 @@ fi
179
192
  # **欄が無い登録で既存の素性を消さない**(upsert)ことが前提である。
180
193
  # effort は渡された時だけ入れる——欄が無い=「不明」ではなく「CLI 既定で走っている」。
181
194
  # ここが失敗しても席は着席済みなので落とさない。ただし黙っては飲まない。
182
- meta=$(python3 - "$name" "$vendor" "$model" "$effort" <<'PY'
195
+ meta=$(python3 - "$name" "$vendor" "$model" "$effort" "$sock" "$sess" <<'PY'
183
196
  import json, sys
184
- name, vendor, model, effort = sys.argv[1:5]
185
- 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}}
186
199
  if effort:
187
200
  body['effort'] = effort
188
201
  print(json.dumps(body))
@@ -205,7 +218,7 @@ try:
205
218
  except Exception:
206
219
  members = []
207
220
  m = next((x for x in members if x.get('name') == sys.argv[1]), {})
208
- print('yes' if m.get('model') else 'no')
221
+ print('yes' if m.get('model') and m.get('observe', {}).get('tmux_target') else 'no')
209
222
  PY
210
223
  )
211
224
  if [ "$stored" = yes ]; then
@@ -217,6 +230,12 @@ else
217
230
  echo "metadata の登録に失敗した: 席は着席済みで、参加者一覧に素性が出ないだけ(room の到達性とトークンを確認)" >&2
218
231
  fi
219
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
+
220
239
  if [ -n "$brief" ]; then
221
240
  sleep 2
222
241
  tmux -S "$sock" send-keys -t "$sess" "$brief"
@@ -21,10 +21,12 @@
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
 
28
+ import { resolvePostToken } from './seat-usage.mjs'
29
+
28
30
  const run = promisify(execFile)
29
31
  const [proj, ...rest] = process.argv.slice(2)
30
32
  if (!proj) {
@@ -121,10 +123,12 @@ if (rest[0] === '--stop') process.exit(0)
121
123
 
122
124
  const state = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
123
125
  const { room, server_url: url } = state
124
- const token = process.env.PEERTABLE_POST_TOKEN ?? ''
126
+ // launch-seat.sh:25-27 と同じ解決規則(env が先・無ければ `~/.config/peertable.env`)。
127
+ // **`export` の有無に常駐の生死を握らせない**——seat-status-bridge が同じ依存で4時間死んだ(2026-08-10)
128
+ const token = resolvePostToken(process.env)
125
129
  if (token.length === 0) {
126
130
  // 投稿できないブリッジは何も返せない。起きてから黙って何もしない常駐を作らない
127
- console.error('RUN_BRIDGE_TOKEN_MISSING: PEERTABLE_POST_TOKEN が無い(export し忘れていないか)')
131
+ console.error('RUN_BRIDGE_TOKEN_MISSING: 書込トークンが無い(環境変数 PEERTABLE_POST_TOKEN ~/.config/peertable.env)')
128
132
  process.exit(1)
129
133
  }
130
134
 
@@ -162,12 +166,21 @@ const runSummaries = new Map() // run_ref -> 直近に記録した要約
162
166
  const interventions = new Map() // `${run_ref}\0${task_id}` -> 直近に観測した介入の形
163
167
  const closedRuns = new Set() // closed を観測した run。以後 poll しない
164
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
+ }
165
177
 
166
178
  // **どの run を見るかは `run list` から取る。** 旧版は order の worktree_path から run dir を
167
179
  // 切り出していたが、その order がもう出ないので、装置に聞く形へ変えた。
168
180
  // `selection: 'pull'` だけを対象にする——legacy automatic run は席が居ないので中継しても意味がない。
169
181
  async function pullRunRefs() {
170
182
  const listed = await lattice(['run', 'list', '--json'])
183
+ markReady()
171
184
  return (listed.active_runs ?? [])
172
185
  .filter(entry => entry.selection === 'pull' && typeof entry.run_ref === 'string')
173
186
  .map(entry => entry.run_ref)
@@ -6,21 +6,25 @@
6
6
  // AI は使わない。読むのは `tmux capture-pane -p` の末尾だけで、席へは1バイトも送らない。
7
7
  //
8
8
  // 判定(2026-08-08 実測。**推測でパターンを書かない**):
9
- // busy = pane の末尾に `esc to interrupt` が在る。Claude 席のステータス行にも Codex 席の `Working (…)` にも
10
- // 同じ文字列が入るので **vendor 分岐が要らない**。実行中の動名詞(Cogitating/Coalescing/Effecting/
11
- // Gallivanting/Fermenting/Symbioting…)は**毎回変わる**ので判定に使わない——語で照合すると全席を
12
- // idle と誤判定して、画面が嘘をつく
13
- // dead = tmux セッションが無い/`pane_dead=1`
14
- // idle = 生きていて busy でない
9
+ // busy = pane の末尾に `esc to interrupt` が在る。Claude 席のステータス行にも Codex 席の `Working (…)` にも
10
+ // 同じ文字列が入るので **vendor 分岐が要らない**。実行中の動名詞(Cogitating/Coalescing/Effecting/
11
+ // Gallivanting/Fermenting/Symbioting…)は**毎回変わる**ので判定に使わない——語で照合すると全席を
12
+ // idle と誤判定して、画面が嘘をつく
13
+ // blocked = 既知の承認ダイアログ文言が末尾に在る(`esc to interrupt` は消えている)。2026-08-08 実測:
14
+ // 確認ダイアログで停止した席が busy `esc to interrupt` 不在から idle 側に誤判定され、
15
+ // 動けない席へ卓が代走を申し出るところまで進んだ。判定順は busy → blocked → idle
16
+ // (承認プロンプト表示中は `esc to interrupt` が消えるので、この順で正しい)
17
+ // dead = tmux セッションが無い/`pane_dead=1`
18
+ // idle = 生きていて busy でも blocked でもない
15
19
  //
16
20
  // 送信は「変化した時」+「変化が無くても心拍」の2本立て。変化時だけだと、**bridge が死んだのか状態が
17
21
  // 変わっていないのかを server が区別できない**(決定58 の liveness と cursor の分離と同じ形)。
18
22
  // server 側は最終受信からの経過で `unknown` へ落とす——古い状態を出し続けるのが最悪だから。
19
23
  import { execFileSync } from 'node:child_process'
20
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
24
+ import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs'
21
25
  import { join } from 'node:path'
22
26
 
23
- import { parsePaneTokenHint, supportsMemberObservation } from './seat-usage.mjs'
27
+ import { classifyPaneTail, decideBridgeContinuation, deriveMissingSession, parsePaneTokenHint, resolvePostToken, resolveSeatObservation, resolveTmuxSocket, supportsMemberObservation } from './seat-usage.mjs'
24
28
 
25
29
  const args = process.argv.slice(2)
26
30
  const proj = args[0]
@@ -67,31 +71,46 @@ if (existsSync(pidPath)) {
67
71
  const setup = JSON.parse(readFileSync(setupPath, 'utf8'))
68
72
  const url = setup.server_url
69
73
  const room = setup.room
70
- const token = process.env.PEERTABLE_POST_TOKEN ?? ''
74
+ // launch-seat.sh:25-27 と同じ解決規則(env が先・無ければ `~/.config/peertable.env`)。
75
+ // **起こす側の shell の env に依存しない**——依存していた時、`export` 欠落だけで常駐が丸ごと死んだ
76
+ const token = resolvePostToken(process.env)
71
77
  writeFileSync(pidPath, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }) + '\n')
72
78
 
73
- const tmux = (...a) => { try { return execFileSync('tmux', 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 } }
74
89
 
75
90
  // 監視するのは room の members に居る席だけ。tmux の `peer-*` を全部拾うと、同じマシンで走る別の卓を晒す
76
91
  async function seats() {
77
92
  const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
78
93
  const { members } = await res.json()
79
- return members.map(m => m.name)
94
+ return members
80
95
  }
81
96
 
82
- function readSeat(name, previous, observedAt) {
83
- const target = `peer-${name}`
84
- const dead = tmux('list-panes', '-t', target, '-F', '#{pane_dead}')
85
- if (dead === null) return { status: 'dead', busySince: null, paneTokenHint: null }
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}')
102
+ // セッションが見つからない。tmux 席を持たない member(親など)は一度も観測できないので送らない
103
+ // (`previous` が無い=一度も観測できていない)。過去に観測できていた席が消えたなら実際に落ちた
104
+ if (dead === null) return deriveMissingSession(previous)
86
105
  if (dead.trim().split('\n')[0] === '1') {
87
106
  return { status: 'dead', busySince: null, paneTokenHint: null }
88
107
  }
89
- const pane = tmux('capture-pane', '-t', target, '-p')
108
+ const pane = tmux(target.socket, 'capture-pane', '-t', target.target, '-p')
90
109
  if (pane === null) return { status: 'dead', busySince: null, paneTokenHint: null }
91
110
  const tail = pane.split('\n').slice(-14).join('\n')
92
- const status = tail.includes('esc to interrupt') ? 'busy' : 'idle'
93
- const busySince = status === 'busy'
94
- ? (previous?.status === 'busy' && previous.busySince ? previous.busySince : observedAt)
111
+ const status = classifyPaneTail(tail)
112
+ const busySince = status === 'busy' || status === 'blocked'
113
+ ? ((previous?.status === 'busy' || previous?.status === 'blocked') && previous.busySince ? previous.busySince : observedAt)
95
114
  : null
96
115
  return { status, busySince, paneTokenHint: parsePaneTokenHint(tail) }
97
116
  }
@@ -123,25 +142,67 @@ const last = new Map() // name -> { status, at }
123
142
  let supported = null // server が status を保持する版か(未判定は null)
124
143
  const tokenBucket = value => value === null ? null : Math.floor(value / 1_000)
125
144
 
145
+ // 送信の結果を数えて返す。**呼び出し側が「1件も届いていない」を判定できる形にする**——
146
+ // 数えないと、失敗を1行ずつ吐きながら永久に常駐する(2026-08-10 に4時間そうなった)
147
+ const NOTHING_ATTEMPTED = { attempted: 0, failed: 0 }
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
+
126
180
  async function tick() {
127
- let names
128
- try { names = await seats() } catch (e) { console.error(`seat-status-bridge: members を読めない: ${e.message}`); return }
181
+ let members
182
+ try { members = await seats() } catch (e) { console.error(`seat-status-bridge: members を読めない: ${e.message}`); return NOTHING_ATTEMPTED }
129
183
  // **送る前に、server が status を持つ版かを確かめる。**
130
184
  // 現行の `POST /members` は、既存メンバーでも `<名前> が参加した` を必ず room へ流す(`post()` が
131
185
  // `if (!members.has(name))` の外にある)。status を保持しない版へ投げると、**保存されないうえに
132
186
  // 席全員を起こす system 発言を撒く**——2026-08-08 に私がこれで6件撒いて全席を1ターン起こした。
133
187
  // 保持する版かどうかは GET で分かるので、**分かるまで投げない**。
134
188
  if (supported === null) {
135
- try { supported = await serverKeepsStatus() } catch { return } // 判定できない間は送らない
189
+ try { supported = await serverKeepsStatus() } catch { return NOTHING_ATTEMPTED } // 判定できない間は送らない
190
+ recordReady()
136
191
  if (!supported) console.error('seat-status-bridge: この room サーバーは稼働状態を保持しない版(GET /members に status が無い)。送信すると保存されないうえに system 発言を撒くので、送信しない')
137
192
  }
138
- if (!supported) { console.error(`seat-status-bridge: ${names.length} 席を見たが、server が未対応なので送っていない`); return }
193
+ logResolution(members)
194
+ if (!supported) { console.error(`seat-status-bridge: ${members.length} 席を見たが、server が未対応なので送っていない`); return NOTHING_ATTEMPTED }
139
195
  const now = Date.now()
140
196
  const observedAt = new Date(now).toISOString()
141
197
  let sent = 0
142
- for (const name of names) {
198
+ let skipped = 0
199
+ let failed = 0
200
+ for (const member of members) {
201
+ const { name } = member
143
202
  const prev = last.get(name)
144
- const observation = readSeat(name, prev, observedAt)
203
+ const observation = readSeat(member, prev, observedAt)
204
+ // tmux 席を持たない member(親など)は一度も観測できないので送らない(deriveMissingSession が null を返す)
205
+ if (observation === null) { skipped++; continue }
145
206
  const changed = !prev || prev.status !== observation.status
146
207
  || prev.busySince !== observation.busySince
147
208
  // token表示は実行中に細かく増える。1k未満の差で8秒ごとにPOSTせず、表示精度に合う粒度で送る。
@@ -154,15 +215,49 @@ async function tick() {
154
215
  sent++
155
216
  if (changed) console.error(`seat-status-bridge: ${name} → ${observation.status}${prev ? `(${prev.status} から)` : ''}`)
156
217
  } catch (e) {
218
+ failed++
157
219
  console.error(`seat-status-bridge: ${name} の送信に失敗: ${e.message}`)
158
220
  }
159
221
  }
160
222
  // 0件でも0件と言う(条件付きログにしない。沈黙する失敗を作らない・決定58)
161
- console.error(`seat-status-bridge: ${names.length} 席を見て ${sent} 件送った`)
223
+ console.error(`seat-status-bridge: ${members.length} 席を見て ${sent} 件送った(tmux席を持たず観測対象外: ${skipped})`)
224
+ return { attempted: sent + failed, failed }
225
+ }
226
+
227
+ // **書けることを実証してから常駐に入る。** `serverKeepsStatus()` は GET で「保存する版か」を
228
+ // 確かめる先例なのに、書込側は確かめずに常駐していた——だから「起動は成功したのに1件も届かない」
229
+ // という**見分けのつかない状態**が存在できた(2026-08-10 実測: トークン欠落で全件403のまま4時間)。
230
+ // 送るものが1件も無い tick は失敗ではない(席がまだ立っていない卓が正常にありうる)。
231
+ const FAILED_TICK_LIMIT = 10 // wakeup-bridge の連続失敗停止と同じ本数
232
+
233
+ function die(code, message) {
234
+ console.error(`${code}: ${message}`)
235
+ try { unlinkSync(pidPath) } catch { /* 既に消えている=目的は達成されている */ }
236
+ process.exit(1)
237
+ }
238
+
239
+ let failedTicks = 0
240
+ let provenWritable = false
241
+
242
+ async function guardedTick() {
243
+ const { attempted, failed } = await tick() ?? NOTHING_ATTEMPTED
244
+ const decided = decideBridgeContinuation({ attempted, failed, provenWritable, failedTicks, limit: FAILED_TICK_LIMIT })
245
+ provenWritable = decided.provenWritable
246
+ failedTicks = decided.failedTicks
247
+ if (decided.verdict === 'write_denied') {
248
+ die('SEAT_STATUS_BRIDGE_WRITE_DENIED',
249
+ `送信 ${attempted} 件がすべて失敗し、一度も書けていない。常駐しない——書けない常駐は、`
250
+ + '起動していない場合と同じ結果(点が出ない)を、起動しているように見せる。'
251
+ + '書込トークン(環境変数 PEERTABLE_POST_TOKEN か ~/.config/peertable.env)と room の到達性を確認すること')
252
+ }
253
+ if (decided.verdict === 'unreachable') {
254
+ die('SEAT_STATUS_BRIDGE_UNREACHABLE',
255
+ `${FAILED_TICK_LIMIT} 回連続で全件送信に失敗した。黙って再試行を続けるゾンビを作らない(決定54)`)
256
+ }
162
257
  }
163
258
 
164
259
  process.on('SIGTERM', () => { try { unlinkSync(pidPath) } catch {} process.exit(0) })
165
260
  process.on('SIGINT', () => { try { unlinkSync(pidPath) } catch {} process.exit(0) })
166
261
 
167
- await tick()
168
- if (!once) setInterval(tick, interval * 1000)
262
+ await guardedTick()
263
+ if (!once) setInterval(() => { guardedTick() }, interval * 1000)
@@ -1,5 +1,12 @@
1
+ import { readFileSync, readdirSync } from 'node:fs'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
1
6
  const TOKEN_HINT = /[↓↑]\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmM]?)\s*tokens\b/gu
2
7
 
8
+ const POST_TOKEN_LINE = /^\s*(?:export\s+)?PEERTABLE_POST_TOKEN\s*=\s*(.*)$/
9
+
3
10
  const TOKEN_MULTIPLIER = Object.freeze({
4
11
  '': 1,
5
12
  k: 1_000,
@@ -10,6 +17,142 @@ export function supportsMemberObservation(payload) {
10
17
  return payload?.capabilities?.member_observation_v1 === true
11
18
  }
12
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
+
45
+ /**
46
+ * 明示設定、既定、検証済み発見の順で tmux socket を決める。
47
+ * 見つからない既定値は launch-seat.sh が新しい server を作るために必要なので返す。
48
+ */
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
82
+ }
83
+
84
+ /**
85
+ * `~/.config/peertable.env` の本文から書込トークンを読む。**`export` の有無に依存しない。**
86
+ * 2026-08-10 実測: `export` を落とした設定ファイルを `source` した shell から `nohup node …` で
87
+ * 起こした bridge は、トークンを持たないまま常駐して **4時間 HTTP 403 を撃ち続けた**。
88
+ * 起こす側の shell の書き方に、常駐の生死を握らせない。
89
+ */
90
+ export function parsePostTokenEnvFile(text) {
91
+ if (typeof text !== 'string') return null
92
+ for (const line of text.split('\n')) {
93
+ const matched = POST_TOKEN_LINE.exec(line)
94
+ if (!matched) continue
95
+ let value = matched[1].trim()
96
+ const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
97
+ if (quoted && value.length >= 2) value = value.slice(1, -1)
98
+ if (value) return value
99
+ }
100
+ return null
101
+ }
102
+
103
+ /**
104
+ * launch-seat.sh:25-27 と同じ解決規則で書込トークンを決める(env が先・無ければ設定ファイル)。
105
+ * 規則を二重に書かない——bash 側だけが自力解決を持ち、.mjs 側が起こす側の env に丸投げしていたのが
106
+ * 上記 403 の構造的な原因だった。readFile は試験のための注入口で、既定は実ファイルを読む。
107
+ */
108
+ export function resolvePostToken(env, readFile = path => { try { return readFileSync(path, 'utf8') } catch { return null } }) {
109
+ if (env.PEERTABLE_POST_TOKEN) return env.PEERTABLE_POST_TOKEN
110
+ return parsePostTokenEnvFile(readFile(join(env.HOME || homedir(), '.config', 'peertable.env'))) ?? ''
111
+ }
112
+
113
+ /**
114
+ * 送信結果から常駐を続けてよいかを決める。**「一度も書けていない」と「一度は書けたが今落ちている」を
115
+ * 分ける**——前者は起動不良で、常駐しても「点が出ない」という**起動していない場合と同じ結果**を
116
+ * 「起動している」ように見せてしまう。だから常駐に入らせない。後者は途中の障害なので、
117
+ * 連続 limit 回で止める(黙って再試行を続けるゾンビを作らない・決定54)。
118
+ * 送るものが1件も無い tick は失敗ではない(席がまだ立っていない卓が正常にありうる)。
119
+ */
120
+ export function decideBridgeContinuation({ attempted, failed, provenWritable, failedTicks, limit }) {
121
+ if (attempted === 0) return { verdict: 'idle', provenWritable, failedTicks }
122
+ if (failed < attempted) return { verdict: 'ok', provenWritable: true, failedTicks: 0 }
123
+ const next = failedTicks + 1
124
+ if (!provenWritable) return { verdict: 'write_denied', provenWritable, failedTicks: next }
125
+ return { verdict: next >= limit ? 'unreachable' : 'degraded', provenWritable, failedTicks: next }
126
+ }
127
+
128
+ /**
129
+ * tmux セッションが見つからない席の扱いを決める。**tmux 席を持たない member(親など)は
130
+ * 一度も観測できない**ので `null`(送らない)。**過去に観測できていた席が消えたら実際に落ちた**
131
+ * ので `dead` を返す。previous は直前に送信できた観測(`{status,...}` か undefined)。
132
+ */
133
+ export function deriveMissingSession(previous) {
134
+ return previous ? { status: 'dead', busySince: null, paneTokenHint: null } : null
135
+ }
136
+
137
+ // launch-seat.sh:74-77 と docs/plan.md §11 が実測で記録した既知ダイアログ文言の集合
138
+ export const BLOCKED_MARKERS = [
139
+ '1. Yes, I trust this folder',
140
+ '1. I am using this for local development',
141
+ '1. Yes, continue',
142
+ 'Do you want to proceed?',
143
+ ]
144
+
145
+ /**
146
+ * pane 末尾の生文字列から画面状態を判定する。判定順は busy → blocked → idle
147
+ * (承認プロンプト表示中は `esc to interrupt` が消えるので busy を先に見る)。
148
+ */
149
+ export function classifyPaneTail(tail) {
150
+ if (typeof tail !== 'string') return 'idle'
151
+ if (tail.includes('esc to interrupt')) return 'busy'
152
+ if (BLOCKED_MARKERS.some(marker => tail.includes(marker))) return 'blocked'
153
+ return 'idle'
154
+ }
155
+
13
156
  /**
14
157
  * paneのstatus行が公開しているtoken値だけを読む。
15
158
  * vendor固有のログや課金単価は推測せず、表示が無い席はnullのままにする。
@@ -162,4 +162,17 @@ fi
162
162
  # 手順どおり打っても届かない(suzune の監査で実測・room [1037])。
163
163
  printf '{"room":"%s","server_url":"%s","public_url":"%s","mode":"%s","plan_key":"%s","phases":%s,"added_exclude":%s,"lattice_preexisting":%s,"runtime_preexisting":%s,"added_runtime_exclude":%s,"added_root_mcp":%s,"added_mcp_exclude":%s,"external_pane":%s,"project_json_preexisting":%s,"work_order_adapter":%s,"work_order_spool_ref":"%s","lattice_cli":"%s"}\n' \
164
164
  "$room" "$url" "$public_url" "$mode" "$plan" "$phases_json" "$added_exclude" "$lattice_preexisting" "$runtime_preexisting" "$added_runtime_exclude" "$added_root_mcp" "$added_mcp_exclude" "$external_pane" "$project_json_preexisting" "$work_order_adapter" "$work_order_spool_ref" "$lattice_cli" > "$tdir/setup-state.json"
165
+
166
+ # 稼働状態ブリッジを起こす。**teardown が止めるのと対称にする**——「起こすかは卓の任意」だった間、
167
+ # 手で `nohup` する経路が2つの失敗を招いた: 起こし忘れと、**トークンを持たないシェルで起こす**
168
+ # (2026-08-10 実測: `export` 欠落の設定ファイルを source した shell から起こした常駐が、
169
+ # 4時間 HTTP 403 を撃ち続け、参加者一覧には点が1つも出なかった=起こしていないのと見分けがつかない)。
170
+ # AI は使わず席へも1バイト送らないので卓の作業を邪魔しない。**書けない時は常駐せずに死ぬ**ので、
171
+ # ここで壊れた常駐が黙って残ることは無い。setup-state.json を読むので、必ずその後で起こす。
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
177
+
165
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)