peertable 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ #!/bin/bash
2
+ # 明示DMの本人要請を確認し、親が席を新effortで再起動する。
3
+ # usage: change-effort.sh <project_dir> <member> <effort> [parent_name]
4
+ #
5
+ # 会話contextは引き継がない。作業状態はroomログ・工程正本・gitから再着任で回収する。
6
+ # busy席は止めず、起動失敗時は旧effortでの再起動を1回だけ明示rollbackする。
7
+ set -eu
8
+
9
+ proj="${1:-}"; name="${2:-}"; effort="${3:-}"; parent="${4:-bell}"
10
+ [ -n "$proj" ] && [ -n "$name" ] && [ -n "$effort" ] || {
11
+ echo "EFFORT_CHANGE_ARGS_INVALID: usage: change-effort.sh <project_dir> <member> <effort> [parent_name]" >&2
12
+ exit 2
13
+ }
14
+ case "$name:$parent" in
15
+ *[!A-Za-z0-9._:-]*) echo "EFFORT_CHANGE_ARGS_INVALID: member/parent名に使えない文字がある" >&2; exit 2 ;;
16
+ esac
17
+
18
+ state="$proj/.team/setup-state.json"
19
+ [ -f "$state" ] || { echo "EFFORT_CHANGE_STATE_MISSING: $state" >&2; exit 1; }
20
+ read -r room url <<EOF
21
+ $(python3 -c "import json;d=json.load(open('$state'));print(d['room'],d['server_url'])")
22
+ EOF
23
+
24
+ if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; then
25
+ . "$HOME/.config/peertable.env"
26
+ fi
27
+ [ -n "${PEERTABLE_POST_TOKEN:-}" ] || { echo "EFFORT_CHANGE_TOKEN_MISSING" >&2; exit 1; }
28
+
29
+ members=$(curl -sf "$url/api/$room/members") || {
30
+ echo "EFFORT_CHANGE_ROOM_UNREACHABLE: membersを読めない" >&2; exit 1;
31
+ }
32
+ meta=$(printf '%s' "$members" | python3 -c '
33
+ import json,sys
34
+ name=sys.argv[1]
35
+ member=next((m for m in json.load(sys.stdin).get("members",[]) if m.get("name")==name),None)
36
+ if not member or member.get("vendor") not in ("claude","codex") or not member.get("model"):
37
+ raise SystemExit(1)
38
+ print("\t".join((member["vendor"],member["model"],member.get("effort") or "")))
39
+ ' "$name") || { echo "EFFORT_CHANGE_MEMBER_METADATA_MISSING: ${name} のvendor/modelが要る" >&2; exit 1; }
40
+ IFS=$'\t' read -r vendor model old_effort <<EOF
41
+ $meta
42
+ EOF
43
+
44
+ case "$vendor" in
45
+ claude)
46
+ case "$effort" in low|medium|high|xhigh|max) ;; *)
47
+ echo "EFFORT_CHANGE_UNSUPPORTED: claude/${model} は low|medium|high|xhigh|max" >&2; exit 1 ;;
48
+ esac
49
+ ;;
50
+ codex)
51
+ catalog=$(codex debug models 2>/dev/null) || {
52
+ echo "EFFORT_CHANGE_MODEL_CATALOG_UNAVAILABLE: codex debug models" >&2; exit 1;
53
+ }
54
+ if ! printf '%s' "$catalog" | python3 -c '
55
+ import json,sys
56
+ model,effort=sys.argv[1:3]
57
+ entry=next((m for m in json.load(sys.stdin).get("models",[]) if m.get("slug")==model),None)
58
+ levels=[] if entry is None else [x.get("effort") for x in entry.get("supported_reasoning_levels",[])]
59
+ raise SystemExit(0 if effort in levels else 1)
60
+ ' "$model" "$effort"; then
61
+ echo "EFFORT_CHANGE_UNSUPPORTED: codex/${model} は effort=${effort} をcatalogで提供していない" >&2
62
+ exit 1
63
+ fi
64
+ ;;
65
+ esac
66
+
67
+ messages=$(curl -sf "$url/api/$room/messages") || {
68
+ echo "EFFORT_CHANGE_ROOM_UNREACHABLE: messagesを読めない" >&2; exit 1;
69
+ }
70
+ request_seq=$(printf '%s' "$messages" | python3 -c '
71
+ import json,sys
72
+ name,parent,effort=sys.argv[1:4]
73
+ rows=json.load(sys.stdin).get("messages",[])
74
+ def addressed(row,target):
75
+ return row.get("to")==target or target in row.get("to_names",[])
76
+ def exact_dm(row,target):
77
+ return row.get("to")==target and not row.get("to_names")
78
+ requests=[r for r in rows if r.get("from")==name and exact_dm(r,parent)
79
+ and r.get("body")==f"[effort変更依頼] {effort}"]
80
+ if not requests: raise SystemExit(1)
81
+ req=max(requests,key=lambda r:r.get("seq",0))
82
+ marker="request #{}".format(req.get("seq",0))
83
+ completed=[r for r in rows if r.get("from")==parent and addressed(r,name)
84
+ and str(r.get("body","")).startswith("[effort変更]")
85
+ and marker in str(r.get("body",""))]
86
+ if completed and max(r.get("seq",0) for r in completed)>=req.get("seq",0): raise SystemExit(1)
87
+ print(req["seq"])
88
+ ' "$name" "$parent" "$effort") || {
89
+ echo "EFFORT_CHANGE_REQUEST_REQUIRED: ${name} → ${parent} の『[effort変更依頼] ${effort}』新着DMが要る" >&2
90
+ exit 1
91
+ }
92
+
93
+ sock="${PEERTABLE_TMUX_SOCKET:-${TMPDIR:-/tmp/}claude-tmux-sockets/claude.sock}"
94
+ sess="peer-$name"
95
+ tmux -S "$sock" has-session -t "$sess" 2>/dev/null || {
96
+ echo "EFFORT_CHANGE_SEAT_MISSING: ${sess}" >&2; exit 1;
97
+ }
98
+ screen=$(tmux -S "$sock" capture-pane -t "$sess" -p -S -25 2>/dev/null) || {
99
+ echo "EFFORT_CHANGE_SEAT_UNREADABLE: $sess" >&2; exit 1;
100
+ }
101
+ case "$screen" in
102
+ *"esc to interrupt"*) echo "EFFORT_CHANGE_SEAT_BUSY: ${sess} は処理中。本人がidleになってから再実行する" >&2; exit 1 ;;
103
+ esac
104
+
105
+ launch="$(dirname "$0")/launch-seat.sh"
106
+ brief="effortが${effort}へ変更され、席を再起動しました。.team/roles/member.mdと工程正本・roomログから再着任し、進行中taskを続けてください。"
107
+ if ! "$launch" "$proj" "$name" "$model" "$vendor" "$effort" "$brief"; then
108
+ echo "EFFORT_CHANGE_RESTART_FAILED: effort=${effort}。旧effort=${old_effort:-default}へrollbackする" >&2
109
+ rollback_brief="effort変更に失敗して旧設定へrollbackしました。.team/roles/member.mdと工程正本・roomログから再着任してください。"
110
+ if "$launch" "$proj" "$name" "$model" "$vendor" "$old_effort" "$rollback_brief"; then
111
+ echo "EFFORT_CHANGE_ROLLED_BACK: ${name} は旧effort=${old_effort:-default}で再着席" >&2
112
+ else
113
+ echo "EFFORT_CHANGE_ROLLBACK_FAILED: ${name} の席を手動で復旧する必要がある" >&2
114
+ fi
115
+ exit 1
116
+ fi
117
+
118
+ members_after=$(curl -sf "$url/api/$room/members") || {
119
+ echo "EFFORT_CHANGE_CHANGED_BUT_UNVERIFIED: 席は再起動済み、membersを読めない" >&2; exit 1;
120
+ }
121
+ if ! printf '%s' "$members_after" | python3 -c '
122
+ import json,sys
123
+ name,effort=sys.argv[1:3]
124
+ m=next((m for m in json.load(sys.stdin).get("members",[]) if m.get("name")==name),{})
125
+ raise SystemExit(0 if m.get("effort")==effort else 1)
126
+ ' "$name" "$effort"; then
127
+ echo "EFFORT_CHANGE_CHANGED_BUT_UNVERIFIED: 席は再起動済み、member metadataがeffort=${effort}でない" >&2
128
+ exit 1
129
+ fi
130
+
131
+ old_label="${old_effort:-default}"
132
+ history=$(python3 -c 'import json,sys;print(json.dumps({"from":sys.argv[1],"to":sys.argv[2],"body":f"[effort変更] {sys.argv[1]} が {sys.argv[2]} の effort を {sys.argv[3]} → {sys.argv[4]} に変更(席を再起動 / request #{sys.argv[5]})"},ensure_ascii=False))' "$parent" "$name" "$old_label" "$effort" "$request_seq")
133
+ if ! curl -sf -o /dev/null -X POST "$url/api/$room/messages" \
134
+ -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" -H 'content-type: application/json' -d "$history"; then
135
+ echo "EFFORT_CHANGE_CHANGED_BUT_HISTORY_FAILED: ${name} はeffort=${effort}で再着席済み、room履歴の記録に失敗" >&2
136
+ exit 1
137
+ fi
138
+
139
+ echo "EFFORT_CHANGE_OK: ${name} ${old_label} → ${effort}(request #${request_seq} / parent=${parent})"
@@ -17,6 +17,10 @@ state="$proj/.team/setup-state.json"
17
17
  read -r room url mode plan <<EOF
18
18
  $(python3 -c "import json;d=json.load(open('$state'));print(d['room'],d['server_url'],d['mode'],d.get('plan_key') or '-')")
19
19
  EOF
20
+ # setup が解決した CLI の実 path。**席が PATH の `lattice` へ逸れないため**に渡す
21
+ # (release 前の source tree では、PATH の install は pull 系 command を持たない)。
22
+ # 無い卓(旧 setup-state)では空になり、席は既定どおり `lattice` を使う。
23
+ lattice_cli=$(python3 -c "import json;print(json.load(open('$state')).get('lattice_cli') or '')")
20
24
 
21
25
  if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; then
22
26
  . "$HOME/.config/peertable.env"
@@ -24,6 +28,11 @@ fi
24
28
 
25
29
  # 前の卓の残骸を回収してから立てる(同名セッションが残ると起動が黙って古い席に化ける)
26
30
  tmux -S "$sock" kill-session -t "$sess" 2>/dev/null || true
31
+
32
+ # 素性記録(.team/seats/<name>.json)の掃除は**ここ**でやる。席を起こす経路は全席が必ず通るので、
33
+ # 死んだ記録がここで必ず消える(ADR 0157)。teardown や人が叩くコマンドに置くと、誰も叩かず溜まる。
34
+ # **消すのは同名の自分の分だけ**——`peer-*` を一括で消すと同じマシンの別卓を巻き込む。
35
+ rm -f "$proj/.team/seats/$name.json"
27
36
  tmux -S "$sock" new-session -d -s "$sess" -x 200 -y 50 -c "$proj"
28
37
 
29
38
  # 素性は席の env にも入れる。client が**登録のたびに**載せるので、member の状態が失われても戻る
@@ -32,6 +41,7 @@ env_line="$env_line PEERTABLE_VENDOR=$vendor PEERTABLE_MODEL=$model"
32
41
  [ -n "$effort" ] && env_line="$env_line PEERTABLE_EFFORT=$effort"
33
42
  if [ "$mode" = "lattice" ]; then
34
43
  env_line="$env_line PEERTABLE_PLAN=$plan LATTICE_TODO_ACTOR_HOST=${LATTICE_TODO_ACTOR_HOST:-mac} LATTICE_TODO_ACTOR_SESSION=$name LATTICE_TODO_ACTOR_AGENT=$name"
44
+ [ -n "$lattice_cli" ] && env_line="$env_line LATTICE_CLI=$lattice_cli"
35
45
  fi
36
46
  tmux -S "$sock" send-keys -t "$sess" "$env_line" Enter
37
47
  sleep 1
@@ -48,6 +58,9 @@ case "$vendor" in
48
58
  # (caveat `codex-cli-v0-130-0-mcp-servers-x-env-block-is-closed-mode-parent-env-not-inherited`)。
49
59
  envtbl="PATH=\\\"$PATH\\\",PEERTABLE_URL=\\\"$url\\\",PEERTABLE_ROOM=\\\"$room\\\",PEERTABLE_MEMBER=\\\"$name\\\",PEERTABLE_POST_TOKEN=\\\"$PEERTABLE_POST_TOKEN\\\""
50
60
  cmd="codex --model $model -C $proj --dangerously-bypass-approvals-and-sandbox"
61
+ # Codexのeffortは環境変数だけでは適用されない。member metadataへ表示する値と、
62
+ # 実際の推論設定を同じ引数から渡して食い違わせない。
63
+ [ -n "$effort" ] && cmd="$cmd -c 'model_reasoning_effort=\"$effort\"'"
51
64
  cmd="$cmd -c 'mcp_servers.room.command=\"peertable-client\"'"
52
65
  cmd="$cmd -c \"mcp_servers.room.env={$envtbl}\""
53
66
  ;;
@@ -94,6 +107,73 @@ fi
94
107
 
95
108
  echo "seated: ${sess}(${vendor} / ${model}${effort:+ / $effort} / room=${room} / mode=${mode})"
96
109
 
110
+ # 席の素性を `.team/seats/<name>.json` へ置く。**席が自分の pid を知るための唯一の経路**である
111
+ # (Lattice の `run intake attach` は expected identity を要求し、pid を推定しない)。
112
+ # 着席の**後**に取る——起動途中の process を掴むと、ダイアログ通過で子が入れ替わりうる。
113
+ #
114
+ # 持たせるのは6欄だけで、**`lattice.pull_worker_attach_input.v1` の exact 集合から `schema` を
115
+ # 除いたもの**と一致する。席は読んで `schema` を被せるだけで attach input になる(変換不要)。
116
+ # **raw argv を持たせない**——Codex 起動の argv には `PEERTABLE_POST_TOKEN` が載るので、
117
+ # 保存すれば秘密の複製になる(2026-08-09 実測)。digest だけを持つ。
118
+ # この file が主張するのは「この pid はこの席だった」という**識別**であって、生死ではない。
119
+ # 生きているかは attach する側(Lattice)が lstart+argv の再観測で確かめる。
120
+ seat_pid=""
121
+ pane_pid=$(tmux -S "$sock" list-panes -t "$sess" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
122
+ if [ -n "$pane_pid" ]; then
123
+ # pane の子で pid===pgid のものが席本体。pane_pid(shell)を渡すと Lattice の直接 OS 観測が
124
+ # 「worker process group を無関係 process と共有している」で正しく落ちる。
125
+ # **1件でなければ推測で選ばない**(run-bridge.mjs の seatWorkerPid と同じ規律)。
126
+ leaders=$(ps -Ao pid=,ppid=,pgid= | awk -v p="$pane_pid" '$2==p && $1==$3 {print $1}')
127
+ if [ "$(printf '%s\n' "$leaders" | grep -c .)" = "1" ]; then seat_pid=$(printf '%s' "$leaders" | tr -d ' \n'); fi
128
+ fi
129
+ if [ -z "$seat_pid" ]; then
130
+ # 記録が無ければ席は attach できず、装置の介入は協調 hold のままになる。**黙らない。**
131
+ echo "seat identity を記録できなかった: ${sess} の process group leader を1つに確定できない(席は着席済み)" >&2
132
+ else
133
+ mkdir -p "$proj/.team/seats"
134
+ # **`session` に tmux 名(`peer-<name>`)を入れてはいけない。** Lattice の attach は
135
+ # `input.session === actor.session` を要求し(`runtime-pull-intake.mjs:674`)、actor session は
136
+ # 上の env_line が入れる `LATTICE_TODO_ACTOR_SESSION=$name` である。tmux 識別子を混ぜると
137
+ # attach が必ず `WORKER_ACTOR_MISMATCH` で拒否される(mio の監査で発覚・room [937])。
138
+ if ! python3 - "$proj/.team/seats/$name.json" "$name" "$name" "$seat_pid" <<'PY'
139
+ import hashlib, json, os, subprocess, sys, tempfile
140
+ out, name, session, pid = sys.argv[1:5]
141
+ pid = int(pid)
142
+ started = subprocess.run(['/bin/ps', '-o', 'lstart=', '-p', str(pid)],
143
+ capture_output=True, text=True, check=True).stdout.strip()
144
+ argv = subprocess.run(['/bin/ps', '-o', 'args=', '-p', str(pid)],
145
+ capture_output=True, text=True, check=True).stdout.strip()
146
+ if not started or not argv:
147
+ sys.exit('pid の lstart/args を観測できない')
148
+ record = {
149
+ 'argv_digest': hashlib.sha256(argv.encode()).hexdigest(),
150
+ 'name': name,
151
+ 'pid': pid,
152
+ 'recorded_at': subprocess.run(['date', '-u', '+%Y-%m-%dT%H:%M:%S.000Z'],
153
+ capture_output=True, text=True, check=True).stdout.strip(),
154
+ 'session': session,
155
+ 'started_identity': started,
156
+ }
157
+ # canonical JSON(key 昇順・空白なし)+ 0600 + 一時file→fsync→rename で原子的に置く。
158
+ # 着席直後に席が読むので、部分読取が起きない形にする。
159
+ body = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n'
160
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(out), prefix='.seat-', suffix='.tmp')
161
+ try:
162
+ with os.fdopen(fd, 'w') as handle:
163
+ handle.write(body)
164
+ handle.flush()
165
+ os.fsync(handle.fileno())
166
+ os.chmod(tmp, 0o600)
167
+ os.replace(tmp, out)
168
+ except BaseException:
169
+ os.unlink(tmp)
170
+ raise
171
+ PY
172
+ then
173
+ echo "seat identity を記録できなかった: ${sess}(席は着席済み・attach は協調 hold のままになる)" >&2
174
+ fi
175
+ fi
176
+
97
177
  # 席の素性(vendor / model / effort)を room へ渡す。参加者一覧のホバー表示に使う。
98
178
  # 席自身の client も起動時に `{name}` だけで登録するので、server 側は
99
179
  # **欄が無い登録で既存の素性を消さない**(upsert)ことが前提である。
@@ -1,14 +1,13 @@
1
1
  #!/bin/bash
2
- # 親(ベル等)が room へ着卓し、必要なら kickoff を投げる。
3
- # usage: parent-join.sh <project_dir> [name] [kickoff_file] [model] [effort]
4
- # name 既定は bell。kickoff_file を渡すとその本文を全員宛で1回投稿する
5
- # (複数行・記号を安全に運ぶためファイル渡しにしている。引数のインライン渡しはしない)。
2
+ # 親(ベル等)が room へ着卓する。
3
+ # usage: parent-join.sh <project_dir> [name] [model] [effort]
4
+ # name 既定は bell。broadcast廃止に伴いkickoff投稿は行わない。
6
5
  # model / effort は任意。親はオーナーの対話セッション(決定40)なので、席と違って
7
6
  # 起動時に確定した値を script が知らない——**渡された時だけ**参加者一覧の素性として登録する。
8
7
  # 渡さなければ欄ごと出ない(「不明」ではなく「素性を名乗っていない」)。
9
8
  # 親は MCP を後付けできないので room へは HTTP API 直で入る(決定40 の operating notes)。
10
9
  set -e
11
- proj="$1"; name="${2:-bell}"; kickoff="$3"; model="$4"; effort="$5"
10
+ proj="$1"; name="${2:-bell}"; model="$3"; effort="$4"
12
11
  state="$proj/.team/setup-state.json"
13
12
  room=$(python3 -c "import json;print(json.load(open('$state'))['room'])")
14
13
  url=$(python3 -c "import json;print(json.load(open('$state'))['server_url'])")
@@ -35,18 +34,4 @@ curl -sf -X POST "$url/api/$room/members" \
35
34
  -d "$member" > /dev/null
36
35
  echo "joined: ${name}(room=${room})"
37
36
 
38
- if [ -n "$kickoff" ]; then
39
- # JSON の組み立てはヒアドキュメントで行う。`-c` にインラインで書くと本文の `{...}`
40
- # をシェルのブレース展開が刻んで壊す(2026-08-08 実測)
41
- body=$(python3 - "$name" "$kickoff" <<'PY'
42
- import json, sys
43
- print(json.dumps({'from': sys.argv[1], 'to': 'all', 'body': open(sys.argv[2]).read().strip()}))
44
- PY
45
- )
46
- curl -sf -X POST "$url/api/$room/messages" \
47
- -H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" -H 'content-type: application/json' \
48
- -d "$body" > /dev/null
49
- echo "kickoff posted: $kickoff"
50
- fi
51
-
52
37
  curl -sf "$url/api/$room/members" | python3 -c "import json,sys;print('members:', ', '.join(m['name'] for m in json.load(sys.stdin)['members']))"