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