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.
- package/README.ja.md +22 -4
- package/README.md +32 -8
- package/package.json +1 -1
- package/room/client.mjs +36 -2
- package/room/server.mjs +52 -12
- package/skill/SKILL.md +12 -8
- package/skill/scripts/archive-room-log.py +7 -3
- package/skill/scripts/ensure-bridge.sh +55 -0
- package/skill/scripts/launch-seat.sh +29 -10
- package/skill/scripts/run-bridge.mjs +16 -3
- package/skill/scripts/seat-status-bridge.mjs +123 -28
- package/skill/scripts/seat-usage.mjs +143 -0
- package/skill/scripts/setup.sh +13 -0
- package/skill/scripts/teardown.sh +19 -11
- package/skill/scripts/tmux-socket.mjs +10 -0
- package/skill/scripts/wakeup-bridge.mjs +34 -4
- package/skill/templates/member-standalone.md +2 -1
- package/skill/templates/member.md +3 -2
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# 席を1つ立てる(tmux 作成 → env 注入 → エージェント起動 → 既知ダイアログ通過 → 着席確認)。
|
|
3
|
-
# usage: launch-seat.sh <project_dir> <name> <model>
|
|
4
|
-
# vendor: claude
|
|
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="$
|
|
12
|
-
[ -n "$proj" ] && [ -n "$name" ] && [ -n "$model" ]
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
+
return members
|
|
80
95
|
}
|
|
81
96
|
|
|
82
|
-
function readSeat(
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
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
|
|
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
|
|
128
|
-
try {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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: ${
|
|
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
|
|
168
|
-
if (!once) setInterval(
|
|
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のままにする。
|
package/skill/scripts/setup.sh
CHANGED
|
@@ -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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 "$
|
|
251
|
+
elif [ -z "$seat_names" ]; then
|
|
246
252
|
skip "メンバー登録の解除(一覧が取れていない)"
|
|
247
253
|
else
|
|
248
254
|
# 履歴に解散の区切りを残す。**部屋が続く以上、どこで卓が変わったかが読めないと
|
|
249
255
|
# 過去ログが一続きの会話に見えてしまう**(次の campaign の発言と地続きになる)
|
|
250
|
-
body="解散。この卓はここまで。参加者: ${
|
|
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
|
-
|
|
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)
|