peertable 0.3.8 → 0.3.10
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 +8 -4
- package/README.md +8 -4
- package/package.json +1 -1
- package/room/client.mjs +36 -56
- package/room/server.mjs +77 -16
- package/skill/SKILL.md +73 -34
- package/skill/scripts/aiterm-configure.mjs +31 -0
- package/skill/scripts/aiterm-launch.mjs +29 -0
- package/skill/scripts/archive-room-log.py +3 -1
- package/skill/scripts/change-effort.sh +5 -129
- package/skill/scripts/change-seat.sh +253 -0
- package/skill/scripts/codex-parent-watch.sh +8 -0
- package/skill/scripts/ensure-bridge.sh +6 -2
- package/skill/scripts/ensure-codex-room-mcp.mjs +152 -0
- package/skill/scripts/ensure-room-mcp.mjs +52 -0
- package/skill/scripts/launch-seat.sh +392 -94
- package/skill/scripts/leave-seat.sh +90 -0
- package/skill/scripts/parent-join.sh +46 -6
- package/skill/scripts/parent-watch.mjs +286 -0
- package/skill/scripts/seat-credential.mjs +118 -0
- package/skill/scripts/seat-status-bridge.mjs +10 -3
- package/skill/scripts/seat-usage.mjs +44 -3
- package/skill/scripts/setup.sh +30 -3
- package/skill/scripts/teardown.sh +12 -3
- package/skill/scripts/todo-extraction-from-plan.mjs +199 -0
- package/skill/scripts/upgrade-team-assets.sh +224 -0
- package/skill/scripts/wakeup-bridge.mjs +245 -58
- package/skill/templates/charter.md +12 -6
- package/skill/templates/done.sh +379 -36
- package/skill/templates/mcp.json +1 -1
- package/skill/templates/member-standalone.md +24 -14
- package/skill/templates/member.md +31 -33
- package/skill/templates/parent.md +128 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 席の vendor / model / effort を変更する。
|
|
3
|
+
# usage: change-seat.sh <project_dir> <member> [--vendor <vendor>] [--model <model>] [--effort <effort>] [--parent <name>] [--reason <text>]
|
|
4
|
+
#
|
|
5
|
+
# **自然文の依頼を再解釈しない。** 依頼の意味・本人の意図・変更してよい局面を判断するのは親である AI で、
|
|
6
|
+
# この script が受け取るのは親が確定した target だけである(旧 change-effort.sh の
|
|
7
|
+
# 「本人→親の単独DMが `[effort変更依頼] <level>` と完全一致すること」という機械判定は廃止した。
|
|
8
|
+
# 明確な自然文の依頼を、同じ文面の再送を求めて拒否していたため)。
|
|
9
|
+
# この script が持つのは、親には出来ない外部境界の仕事だけである:
|
|
10
|
+
# 現在の素性の取得 / target の live catalog 検証 / 同値no-op / busy保護 / 再起動 /
|
|
11
|
+
# metadata の読み返し / room 履歴 / 失敗時の旧設定への1回だけの明示rollback。
|
|
12
|
+
#
|
|
13
|
+
# 会話contextは引き継がない。作業状態はroomログ・工程正本・gitから再着任で回収する。
|
|
14
|
+
set -eu
|
|
15
|
+
|
|
16
|
+
# token値はこの制御processや再起動する席へ継承しない。launch後のroom記録も席別file経由で行う。
|
|
17
|
+
unset PEERTABLE_POST_TOKEN
|
|
18
|
+
script_dir=$(cd "$(dirname "$0")" && pwd -P)
|
|
19
|
+
credential_helper="${PEERTABLE_CREDENTIAL_HELPER:-$script_dir/seat-credential.mjs}"
|
|
20
|
+
|
|
21
|
+
proj="${1:-}"; name="${2:-}"
|
|
22
|
+
shift 2 2>/dev/null || true
|
|
23
|
+
opt_vendor=""; opt_model=""; opt_effort=""; parent="bell"; reason=""
|
|
24
|
+
while [ $# -gt 0 ]; do
|
|
25
|
+
case "$1" in
|
|
26
|
+
--vendor) opt_vendor="${2:-}"; shift 2 || true ;;
|
|
27
|
+
--model) opt_model="${2:-}"; shift 2 || true ;;
|
|
28
|
+
--effort) opt_effort="${2:-}"; shift 2 || true ;;
|
|
29
|
+
--parent) parent="${2:-}"; shift 2 || true ;;
|
|
30
|
+
--reason) reason="${2:-}"; shift 2 || true ;;
|
|
31
|
+
*) echo "SEAT_CHANGE_ARGS_INVALID: 不明な引数 $1" >&2; exit 2 ;;
|
|
32
|
+
esac
|
|
33
|
+
done
|
|
34
|
+
[ -n "$proj" ] && [ -n "$name" ] || {
|
|
35
|
+
echo "SEAT_CHANGE_ARGS_INVALID: usage: change-seat.sh <project_dir> <member> [--vendor <vendor>] [--model <model>] [--effort <effort>] [--parent <name>] [--reason <text>]" >&2
|
|
36
|
+
exit 2
|
|
37
|
+
}
|
|
38
|
+
[ -n "$opt_model" ] || [ -n "$opt_effort" ] || {
|
|
39
|
+
echo "SEAT_CHANGE_ARGS_INVALID: --model と --effort の少なくとも一方が要る" >&2; exit 2
|
|
40
|
+
}
|
|
41
|
+
case "$name:$parent" in
|
|
42
|
+
*[!A-Za-z0-9._:-]*) echo "SEAT_CHANGE_ARGS_INVALID: member/parent名に使えない文字がある" >&2; exit 2 ;;
|
|
43
|
+
esac
|
|
44
|
+
|
|
45
|
+
state="$proj/.team/setup-state.json"
|
|
46
|
+
[ -f "$state" ] || { echo "SEAT_CHANGE_STATE_MISSING: $state" >&2; exit 1; }
|
|
47
|
+
read -r room url <<EOF
|
|
48
|
+
$(python3 -c "import json;d=json.load(open('$state'));print(d['room'],d['server_url'])")
|
|
49
|
+
EOF
|
|
50
|
+
|
|
51
|
+
members=$(curl -sf "$url/api/$room/members") || {
|
|
52
|
+
echo "SEAT_CHANGE_ROOM_UNREACHABLE: membersを読めない" >&2; exit 1;
|
|
53
|
+
}
|
|
54
|
+
meta=$(printf '%s' "$members" | python3 -c '
|
|
55
|
+
import json,sys
|
|
56
|
+
name=sys.argv[1]
|
|
57
|
+
member=next((m for m in json.load(sys.stdin).get("members",[]) if m.get("name")==name),None)
|
|
58
|
+
if not member or member.get("vendor") not in ("claude","codex") or not member.get("model"):
|
|
59
|
+
raise SystemExit(1)
|
|
60
|
+
print("\t".join((member["vendor"],member["model"],member.get("effort") or "",member.get("aiterm_session_id") or "")))
|
|
61
|
+
' "$name") || { echo "SEAT_CHANGE_MEMBER_METADATA_MISSING: ${name} のvendor/modelが要る" >&2; exit 1; }
|
|
62
|
+
IFS=$'\t' read -r old_vendor old_model old_effort aiterm_session_id <<EOF
|
|
63
|
+
$meta
|
|
64
|
+
EOF
|
|
65
|
+
|
|
66
|
+
vendor="${opt_vendor:-$old_vendor}"
|
|
67
|
+
case "$vendor" in
|
|
68
|
+
claude|codex) ;;
|
|
69
|
+
*) echo "SEAT_CHANGE_VENDOR_UNSUPPORTED: vendor=${vendor}(claude / codex のみ)" >&2; exit 2 ;;
|
|
70
|
+
esac
|
|
71
|
+
if [ "$vendor" != "$old_vendor" ] && { [ -z "$opt_model" ] || [ -z "$opt_effort" ]; }; then
|
|
72
|
+
echo "SEAT_CHANGE_ARGS_INVALID: vendor変更には --model と --effort の明示指定が要る" >&2
|
|
73
|
+
exit 2
|
|
74
|
+
fi
|
|
75
|
+
model="${opt_model:-$old_model}"
|
|
76
|
+
effort="${opt_effort:-$old_effort}"
|
|
77
|
+
# effort を持たない席(CLI 既定で走っている席)へ model だけ渡すと、再起動で effort が確定してしまう。
|
|
78
|
+
# 既定値をここへ埋めない——launch-seat.sh と同じく「席を立てる時に決める」(オーナー裁定)。
|
|
79
|
+
[ -n "$effort" ] || {
|
|
80
|
+
echo "SEAT_CHANGE_EFFORT_UNKNOWN: ${name} の現在effortがmetadataに無い。--effort を明示する" >&2; exit 1
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if [ "$model" = "$old_model" ] && [ "$effort" = "$old_effort" ] && [ "$vendor" = "$old_vendor" ]; then
|
|
84
|
+
echo "SEAT_CHANGE_NOOP: ${name} は既に model=${model} / effort=${effort}(再起動しない)"
|
|
85
|
+
exit 0
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
sock="${PEERTABLE_TMUX_SOCKET:-${TMPDIR:-/tmp/}claude-tmux-sockets/claude.sock}"
|
|
89
|
+
sess="peer-$name"
|
|
90
|
+
tmux -S "$sock" has-session -t "$sess" 2>/dev/null || {
|
|
91
|
+
echo "SEAT_CHANGE_SEAT_MISSING: ${sess}" >&2; exit 1;
|
|
92
|
+
}
|
|
93
|
+
screen=$(tmux -S "$sock" capture-pane -t "$sess" -p -S -25 2>/dev/null) || {
|
|
94
|
+
echo "SEAT_CHANGE_SEAT_UNREADABLE: $sess" >&2; exit 1;
|
|
95
|
+
}
|
|
96
|
+
# busy の判定文字列は seat-status-bridge と同じ(Claude のステータス行にも Codex の `Working (…)` にも出る)
|
|
97
|
+
case "$screen" in
|
|
98
|
+
*"esc to interrupt"*) echo "SEAT_CHANGE_SEAT_BUSY: ${sess} は処理中。本人がidleになってから再実行する" >&2; exit 1 ;;
|
|
99
|
+
esac
|
|
100
|
+
|
|
101
|
+
# target の検証は live 面だけを使い、古くなる hardcode を足さない。
|
|
102
|
+
case "$vendor" in
|
|
103
|
+
claude)
|
|
104
|
+
# Claude には非破壊で引ける model catalog が無い(`--help` の alias 例は catalog ではなく、
|
|
105
|
+
# 実際 2026-08-11 に `fable` は例に載ったまま live では unavailable だった)。
|
|
106
|
+
# よって **model 名は事前検証しない**——実 CLI の起動失敗と rollback が正式な検証境界である。
|
|
107
|
+
# effort は `--help` が live に列挙するので、そこから取る。
|
|
108
|
+
help_text=$(claude --help 2>/dev/null) || {
|
|
109
|
+
echo "SEAT_CHANGE_EFFORT_CATALOG_UNAVAILABLE: claude --help を読めない" >&2; exit 1;
|
|
110
|
+
}
|
|
111
|
+
levels=$(printf '%s' "$help_text" | python3 -c '
|
|
112
|
+
import re,sys
|
|
113
|
+
text=sys.stdin.read()
|
|
114
|
+
i=text.find("--effort")
|
|
115
|
+
m=re.search(r"\(([a-z0-9, ]+)\)", text[i:i+400]) if i>=0 else None
|
|
116
|
+
if not m: raise SystemExit(1)
|
|
117
|
+
print(" ".join(x.strip() for x in m.group(1).split(",") if x.strip()))
|
|
118
|
+
') || {
|
|
119
|
+
echo "SEAT_CHANGE_EFFORT_CATALOG_UNAVAILABLE: claude --help が effort の水準を列挙しない" >&2; exit 1;
|
|
120
|
+
}
|
|
121
|
+
case " $levels " in
|
|
122
|
+
*" $effort "*) ;;
|
|
123
|
+
*) echo "SEAT_CHANGE_EFFORT_UNSUPPORTED: claude は effort=${effort} を提供していない(live: ${levels})" >&2; exit 1 ;;
|
|
124
|
+
esac
|
|
125
|
+
;;
|
|
126
|
+
codex)
|
|
127
|
+
catalog=$(codex debug models 2>/dev/null) || {
|
|
128
|
+
echo "SEAT_CHANGE_MODEL_CATALOG_UNAVAILABLE: codex debug models" >&2; exit 1;
|
|
129
|
+
}
|
|
130
|
+
verdict=$(printf '%s' "$catalog" | python3 -c '
|
|
131
|
+
import json,sys
|
|
132
|
+
model,effort=sys.argv[1:3]
|
|
133
|
+
entry=next((m for m in json.load(sys.stdin).get("models",[]) if m.get("slug")==model),None)
|
|
134
|
+
if entry is None:
|
|
135
|
+
print("model"); raise SystemExit(0)
|
|
136
|
+
levels=[x.get("effort") for x in entry.get("supported_reasoning_levels",[])]
|
|
137
|
+
print("ok" if effort in levels else "effort")
|
|
138
|
+
' "$model" "$effort") || {
|
|
139
|
+
echo "SEAT_CHANGE_MODEL_CATALOG_UNAVAILABLE: codex debug models の出力を読めない" >&2; exit 1;
|
|
140
|
+
}
|
|
141
|
+
case "$verdict" in
|
|
142
|
+
model) echo "SEAT_CHANGE_MODEL_UNSUPPORTED: codex catalog に model=${model} が無い" >&2; exit 1 ;;
|
|
143
|
+
effort) echo "SEAT_CHANGE_EFFORT_UNSUPPORTED: codex/${model} は effort=${effort} をcatalogで提供していない" >&2; exit 1 ;;
|
|
144
|
+
esac
|
|
145
|
+
;;
|
|
146
|
+
esac
|
|
147
|
+
|
|
148
|
+
changes=""
|
|
149
|
+
[ "$vendor" = "$old_vendor" ] || changes="vendor ${old_vendor} → ${vendor}"
|
|
150
|
+
[ "$model" = "$old_model" ] || {
|
|
151
|
+
[ -z "$changes" ] || changes="$changes / "
|
|
152
|
+
changes="${changes}model ${old_model} → ${model}"
|
|
153
|
+
}
|
|
154
|
+
if [ "$effort" != "$old_effort" ]; then
|
|
155
|
+
[ -z "$changes" ] || changes="$changes / "
|
|
156
|
+
changes="${changes}effort ${old_effort:-default} → ${effort}"
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
credential_file=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" path "$proj" "$room" "$name") || {
|
|
160
|
+
echo "SEAT_CHANGE_CREDENTIAL_MISSING: ${name} のroom credentialを特定できない" >&2; exit 1
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if [ "$vendor" = "$old_vendor" ]; then
|
|
164
|
+
[ -n "$aiterm_session_id" ] || {
|
|
165
|
+
echo "SEAT_CHANGE_AITERM_SESSION_MISSING: ${name} にAiterm managed session_idが無い" >&2; exit 1
|
|
166
|
+
}
|
|
167
|
+
configure_args=("$aiterm_session_id")
|
|
168
|
+
[ -z "$opt_model" ] || configure_args+=(--model "$model")
|
|
169
|
+
[ -z "$opt_effort" ] || configure_args+=(--effort "$effort")
|
|
170
|
+
node "$script_dir/aiterm-configure.mjs" "${configure_args[@]}" >/dev/null || {
|
|
171
|
+
echo "SEAT_CHANGE_AITERM_CONFIGURE_FAILED: ${name} の設定は変更していない" >&2; exit 1
|
|
172
|
+
}
|
|
173
|
+
identity=$(python3 -c 'import json,sys;print(json.dumps({"name":sys.argv[1],"vendor":sys.argv[2],"model":sys.argv[3],"effort":sys.argv[4],"aiterm_session_id":sys.argv[5]}))' "$name" "$vendor" "$model" "$effort" "$aiterm_session_id")
|
|
174
|
+
env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" POST \
|
|
175
|
+
"$url/api/$room/members" "$identity" >/dev/null || {
|
|
176
|
+
echo "SEAT_CHANGE_CHANGED_BUT_METADATA_FAILED: ${name} は設定済み、room metadataを同期できない" >&2; exit 1
|
|
177
|
+
}
|
|
178
|
+
change_method="同一sessionを維持"
|
|
179
|
+
else
|
|
180
|
+
leave="$script_dir/leave-seat.sh"
|
|
181
|
+
if ! "$leave" "$proj" "$name"; then
|
|
182
|
+
echo "SEAT_CHANGE_RESTART_PREPARE_FAILED: ${name} の旧席を安全に撤去できないため再起動しない" >&2
|
|
183
|
+
exit 1
|
|
184
|
+
fi
|
|
185
|
+
launch="$script_dir/launch-seat.sh"
|
|
186
|
+
brief="席設定が変更され(${changes})、席を再起動しました。.team/roles/member.mdと工程正本・roomログから再着任し、進行中taskを続けてください。"
|
|
187
|
+
if ! "$launch" "$proj" "$name" "$model" "$vendor" "$effort" "$brief"; then
|
|
188
|
+
echo "SEAT_CHANGE_RESTART_FAILED: ${changes}。旧設定(vendor=${old_vendor} / model=${old_model} / effort=${old_effort:-default})へrollbackする" >&2
|
|
189
|
+
rollback_brief="席設定の変更に失敗して旧設定へrollbackしました。.team/roles/member.mdと工程正本・roomログから再着任してください。"
|
|
190
|
+
if "$launch" "$proj" "$name" "$old_model" "$old_vendor" "$old_effort" "$rollback_brief"; then
|
|
191
|
+
echo "SEAT_CHANGE_ROLLED_BACK: ${name} は vendor=${old_vendor} / model=${old_model} / effort=${old_effort:-default} で再着席" >&2
|
|
192
|
+
else
|
|
193
|
+
echo "SEAT_CHANGE_ROLLBACK_FAILED: ${name} の席を手動で復旧する必要がある" >&2
|
|
194
|
+
fi
|
|
195
|
+
exit 1
|
|
196
|
+
fi
|
|
197
|
+
change_method="席を再起動"
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
members_after=$(curl -sf "$url/api/$room/members") || {
|
|
201
|
+
echo "SEAT_CHANGE_CHANGED_BUT_UNVERIFIED: 席は再起動済み、membersを読めない" >&2; exit 1;
|
|
202
|
+
}
|
|
203
|
+
if ! printf '%s' "$members_after" | python3 -c '
|
|
204
|
+
import json,sys
|
|
205
|
+
name,vendor,model,effort=sys.argv[1:5]
|
|
206
|
+
m=next((m for m in json.load(sys.stdin).get("members",[]) if m.get("name")==name),{})
|
|
207
|
+
raise SystemExit(0 if m.get("vendor")==vendor and m.get("model")==model and m.get("effort")==effort else 1)
|
|
208
|
+
' "$name" "$vendor" "$model" "$effort"; then
|
|
209
|
+
echo "SEAT_CHANGE_CHANGED_BUT_UNVERIFIED: 席は再起動済み、member metadataが vendor=${vendor} / model=${model} / effort=${effort} でない" >&2
|
|
210
|
+
exit 1
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
body="[席設定変更] ${parent} が ${name} の ${changes} に変更(${change_method})"
|
|
214
|
+
[ -z "$reason" ] || body="${body}。理由: ${reason}"
|
|
215
|
+
history=$(python3 -c 'import json,sys;print(json.dumps({"from":sys.argv[1],"to":sys.argv[2],"body":sys.argv[3]},ensure_ascii=False))' "$parent" "$name" "$body")
|
|
216
|
+
history_response=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" POST \
|
|
217
|
+
"$url/api/$room/messages" "$history") || {
|
|
218
|
+
echo "SEAT_CHANGE_CHANGED_BUT_HISTORY_FAILED: ${name} は ${changes} で再着席済み、room履歴の記録に失敗" >&2
|
|
219
|
+
exit 1
|
|
220
|
+
}
|
|
221
|
+
history_seq=$(printf '%s' "$history_response" | python3 -c '
|
|
222
|
+
import json,sys
|
|
223
|
+
try:
|
|
224
|
+
seq=json.load(sys.stdin).get("seq")
|
|
225
|
+
except (ValueError, TypeError):
|
|
226
|
+
raise SystemExit(1)
|
|
227
|
+
print(seq)
|
|
228
|
+
raise SystemExit(0 if isinstance(seq, int) else 1)
|
|
229
|
+
') || {
|
|
230
|
+
echo "SEAT_CHANGE_CHANGED_BUT_HISTORY_FAILED: ${name} は ${changes} で再着席済み、room履歴POST応答のseqを読めない" >&2
|
|
231
|
+
exit 1
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
messages_after=$(env -u PEERTABLE_POST_TOKEN node "$credential_helper" request "$credential_file" GET \
|
|
235
|
+
"$url/api/$room/messages") || {
|
|
236
|
+
echo "SEAT_CHANGE_CHANGED_BUT_HISTORY_FAILED: ${name} は ${changes} で再着席済み、room履歴を読み返せない" >&2
|
|
237
|
+
exit 1
|
|
238
|
+
}
|
|
239
|
+
if ! printf '%s' "$messages_after" | python3 -c '
|
|
240
|
+
import json,sys
|
|
241
|
+
seq,parent,name,body=sys.argv[1:5]
|
|
242
|
+
try:
|
|
243
|
+
messages=json.load(sys.stdin).get("messages",[])
|
|
244
|
+
except (ValueError, TypeError):
|
|
245
|
+
raise SystemExit(1)
|
|
246
|
+
matched=next((m for m in messages if str(m.get("seq")) == seq), None)
|
|
247
|
+
raise SystemExit(0 if matched and matched.get("from") == parent and matched.get("to") == name and matched.get("body") == body else 1)
|
|
248
|
+
' "$history_seq" "$parent" "$name" "$body"; then
|
|
249
|
+
echo "SEAT_CHANGE_CHANGED_BUT_HISTORY_FAILED: ${name} は ${changes} で再着席済み、room履歴の読返しがtargetと一致しない" >&2
|
|
250
|
+
exit 1
|
|
251
|
+
fi
|
|
252
|
+
|
|
253
|
+
echo "SEAT_CHANGE_OK: ${name} ${changes}(parent=${parent})"
|
|
@@ -10,7 +10,11 @@ if [ "${1:-}" = "--force" ]; then force=true; shift; fi
|
|
|
10
10
|
if [ $# -eq 0 ] && [ -f "$record" ]; then
|
|
11
11
|
saved=()
|
|
12
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
|
|
13
|
+
# macOS 標準 bash 3.2 の set -u では、空配列の展開が unbound variable になる。
|
|
14
|
+
# args が空なら現在の「引数なし」を保ち、値がある時だけ復元する。
|
|
15
|
+
if [ "${#saved[@]}" -gt 0 ]; then
|
|
16
|
+
set -- "${saved[@]}"
|
|
17
|
+
fi
|
|
14
18
|
fi
|
|
15
19
|
if [ -f "$record" ]; then
|
|
16
20
|
pid=$(node -e 'try{process.stdout.write(String(require(process.argv[1]).pid||""))}catch{}' "$record")
|
|
@@ -29,7 +33,7 @@ rm -f "$record"
|
|
|
29
33
|
# 呼び出し元 client の環境ではないので、素で起こすと `PEERTABLE_TMUX_SOCKET` の手渡しが黙って消え、
|
|
30
34
|
# 常駐が別の socket(本番の既定)を観測しにいく(2026-08-11 実測)。決定73 と同じ形の裏返しである。
|
|
31
35
|
env_prefix=""
|
|
32
|
-
for v in PEERTABLE_TMUX_SOCKET PEERTABLE_POST_TOKEN PEERTABLE_URL; do
|
|
36
|
+
for v in PEERTABLE_TMUX_SOCKET PEERTABLE_POST_TOKEN PEERTABLE_CREDENTIAL_FILE PEERTABLE_URL PEERTABLE_PARENT_NAME; do
|
|
33
37
|
eval "val=\${$v:-}"
|
|
34
38
|
[ -n "$val" ] && env_prefix="$env_prefix $v=$(printf '%q' "$val")"
|
|
35
39
|
done
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Codexが読むproject設定へ、Peertable所有のroom MCP blockだけを追加・撤去する。
|
|
3
|
+
import {
|
|
4
|
+
chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync,
|
|
5
|
+
renameSync, rmdirSync, statSync, unlinkSync, writeFileSync,
|
|
6
|
+
} from 'node:fs'
|
|
7
|
+
import { dirname, join, resolve } from 'node:path'
|
|
8
|
+
import { execFileSync } from 'node:child_process'
|
|
9
|
+
|
|
10
|
+
const fail = (code, message) => {
|
|
11
|
+
process.stderr.write(`${code}: ${message}\n`)
|
|
12
|
+
process.exit(1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const [action, project, peertableRepo] = process.argv.slice(2)
|
|
16
|
+
if (!['ensure', 'remove'].includes(action) || !project || !peertableRepo)
|
|
17
|
+
fail('SEAT_CODEX_ROOM_MCP_ARGS_INVALID', '<ensure|remove> <project> <peertable_repo>')
|
|
18
|
+
|
|
19
|
+
const configDir = resolve(project, '.codex')
|
|
20
|
+
const configFile = join(configDir, 'config.toml')
|
|
21
|
+
const startPattern = /^# BEGIN PEERTABLE ROOM MCP added_newline=([01])$/mu
|
|
22
|
+
const endMarker = '# END PEERTABLE ROOM MCP'
|
|
23
|
+
const roomHeader = /^\s*\[mcp_servers\.room\]\s*$/mu
|
|
24
|
+
const seatEnvNames = [
|
|
25
|
+
'PEERTABLE_URL', 'PEERTABLE_ROOM', 'PEERTABLE_MEMBER', 'PEERTABLE_CREDENTIAL_FILE',
|
|
26
|
+
'PEERTABLE_VENDOR', 'PEERTABLE_MODEL', 'PEERTABLE_EFFORT', 'PEERTABLE_ROLE',
|
|
27
|
+
'PEERTABLE_PLAN', 'LATTICE_CLI', 'LATTICE_TODO_ACTOR_HOST',
|
|
28
|
+
'LATTICE_TODO_ACTOR_SESSION', 'LATTICE_TODO_ACTOR_AGENT',
|
|
29
|
+
]
|
|
30
|
+
const requiredSeatEnv = seatEnvNames.slice(0, 8)
|
|
31
|
+
|
|
32
|
+
function atomicWrite(file, body, mode = 0o644) {
|
|
33
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
34
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
35
|
+
let fd
|
|
36
|
+
try {
|
|
37
|
+
fd = openSync(temporary, 'wx', mode)
|
|
38
|
+
writeFileSync(fd, body, 'utf8')
|
|
39
|
+
fsyncSync(fd)
|
|
40
|
+
closeSync(fd)
|
|
41
|
+
fd = undefined
|
|
42
|
+
renameSync(temporary, file)
|
|
43
|
+
chmodSync(file, mode)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (fd !== undefined) closeSync(fd)
|
|
46
|
+
try { unlinkSync(temporary) } catch {}
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function markerRange(text) {
|
|
52
|
+
const starts = [...text.matchAll(new RegExp(startPattern.source, 'gmu'))]
|
|
53
|
+
const ends = [...text.matchAll(new RegExp(`^${endMarker}$`, 'gmu'))]
|
|
54
|
+
if (starts.length === 0 && ends.length === 0) return null
|
|
55
|
+
if (starts.length !== 1 || ends.length !== 1 || ends[0].index < starts[0].index)
|
|
56
|
+
fail('SEAT_CODEX_ROOM_MCP_UNREADABLE', `${configFile} のPeertable block境界が壊れている`)
|
|
57
|
+
let end = ends[0].index + endMarker.length
|
|
58
|
+
if (text[end] === '\n') end += 1
|
|
59
|
+
return { start: starts[0].index, end, addedNewline: starts[0][1] === '1' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function expectedBlock(addedNewline) {
|
|
63
|
+
const client = resolve(peertableRepo, 'room', 'client.mjs')
|
|
64
|
+
const explicitEnv = seatEnvNames
|
|
65
|
+
.filter((name) => process.env[name] !== undefined && process.env[name] !== '')
|
|
66
|
+
.map((name) => `${name} = ${JSON.stringify(process.env[name])}`)
|
|
67
|
+
return [
|
|
68
|
+
`# BEGIN PEERTABLE ROOM MCP added_newline=${addedNewline ? '1' : '0'}`,
|
|
69
|
+
'[mcp_servers.room]',
|
|
70
|
+
'command = "node"',
|
|
71
|
+
`args = [${JSON.stringify(client)}]`,
|
|
72
|
+
'env_vars = ["TMUX", "TMUX_PANE"]',
|
|
73
|
+
'[mcp_servers.room.env]',
|
|
74
|
+
...explicitEnv,
|
|
75
|
+
endMarker,
|
|
76
|
+
'',
|
|
77
|
+
].join('\n')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function gitExcludePath() {
|
|
81
|
+
try {
|
|
82
|
+
return execFileSync('git', [
|
|
83
|
+
'-C', project, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude',
|
|
84
|
+
], {
|
|
85
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
86
|
+
}).trim()
|
|
87
|
+
} catch { return '' }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ensureExclude() {
|
|
91
|
+
const file = gitExcludePath()
|
|
92
|
+
if (!file) return
|
|
93
|
+
const marker = '# peertable:codex-room-mcp'
|
|
94
|
+
const rule = '/.codex/config.toml'
|
|
95
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
96
|
+
if (text.split(/\r?\n/u).includes(rule)) return
|
|
97
|
+
const prefix = text.length === 0 || text.endsWith('\n') ? text : `${text}\n`
|
|
98
|
+
atomicWrite(file, `${prefix}${marker}\n${rule}\n`, existsSync(file) ? statSync(file).mode & 0o777 : 0o644)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function removeExclude() {
|
|
102
|
+
const file = gitExcludePath()
|
|
103
|
+
if (!file || !existsSync(file)) return
|
|
104
|
+
const lines = readFileSync(file, 'utf8').split('\n')
|
|
105
|
+
const marker = '# peertable:codex-room-mcp'
|
|
106
|
+
const rule = '/.codex/config.toml'
|
|
107
|
+
const index = lines.findIndex((line, at) => line === marker && lines[at + 1] === rule)
|
|
108
|
+
if (index < 0) return
|
|
109
|
+
lines.splice(index, 2)
|
|
110
|
+
atomicWrite(file, lines.join('\n'), statSync(file).mode & 0o777)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
if (action === 'ensure') {
|
|
115
|
+
const missing = requiredSeatEnv.filter((name) => !process.env[name])
|
|
116
|
+
if (missing.length > 0)
|
|
117
|
+
fail('SEAT_CODEX_ROOM_MCP_ENV_MISSING', `seat環境が無い: ${missing.join(',')}`)
|
|
118
|
+
const current = existsSync(configFile) ? readFileSync(configFile, 'utf8') : ''
|
|
119
|
+
const range = markerRange(current)
|
|
120
|
+
let next
|
|
121
|
+
if (range) {
|
|
122
|
+
next = `${current.slice(0, range.start)}${expectedBlock(range.addedNewline)}${current.slice(range.end)}`
|
|
123
|
+
} else {
|
|
124
|
+
if (roomHeader.test(current))
|
|
125
|
+
fail('SEAT_CODEX_ROOM_MCP_CONFLICT', `${configFile} にproject所有のmcp_servers.roomがある`)
|
|
126
|
+
const addedNewline = current.length > 0 && !current.endsWith('\n')
|
|
127
|
+
next = `${current}${addedNewline ? '\n' : ''}${expectedBlock(addedNewline)}`
|
|
128
|
+
}
|
|
129
|
+
if (next !== current)
|
|
130
|
+
atomicWrite(configFile, next, existsSync(configFile) ? statSync(configFile).mode & 0o777 : 0o644)
|
|
131
|
+
ensureExclude()
|
|
132
|
+
process.stdout.write(`codex room MCP: ${configFile}\n`)
|
|
133
|
+
} else {
|
|
134
|
+
if (existsSync(configFile)) {
|
|
135
|
+
const current = readFileSync(configFile, 'utf8')
|
|
136
|
+
const range = markerRange(current)
|
|
137
|
+
if (range) {
|
|
138
|
+
const start = range.addedNewline && range.start > 0 && current[range.start - 1] === '\n'
|
|
139
|
+
? range.start - 1 : range.start
|
|
140
|
+
const next = `${current.slice(0, start)}${current.slice(range.end)}`
|
|
141
|
+
if (next.length === 0) {
|
|
142
|
+
unlinkSync(configFile)
|
|
143
|
+
try { rmdirSync(configDir) } catch {}
|
|
144
|
+
} else atomicWrite(configFile, next, statSync(configFile).mode & 0o777)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
removeExclude()
|
|
148
|
+
process.stdout.write('codex room MCP: removed\n')
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
fail(action === 'ensure' ? 'SEAT_CODEX_ROOM_MCP_UPDATE_FAILED' : 'SEAT_CODEX_ROOM_MCP_REMOVE_FAILED', error.message)
|
|
152
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude channelsが読むproject rootのroom MCPを、同じPeertable treeのclientへ束縛する。
|
|
3
|
+
import {
|
|
4
|
+
closeSync, fsyncSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
|
|
5
|
+
} from 'node:fs'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const fail = (code, message) => {
|
|
9
|
+
process.stderr.write(`${code}: ${message}\n`)
|
|
10
|
+
process.exit(1)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const [project, peertableRepo, ownership] = process.argv.slice(2)
|
|
14
|
+
if (!project || !peertableRepo || !['managed', 'preexisting'].includes(ownership))
|
|
15
|
+
fail('SEAT_ROOM_MCP_ARGS_INVALID', '<project> <peertable_repo> <managed|preexisting>')
|
|
16
|
+
|
|
17
|
+
const file = resolve(project, '.mcp.json')
|
|
18
|
+
let config
|
|
19
|
+
try { config = JSON.parse(readFileSync(file, 'utf8')) } catch {
|
|
20
|
+
fail('SEAT_ROOM_MCP_UNREADABLE', `${file} をJSONとして読めない`)
|
|
21
|
+
}
|
|
22
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
23
|
+
fail('SEAT_ROOM_MCP_INVALID', `${file} のrootがobjectでない`)
|
|
24
|
+
const expected = { command: 'node', args: [resolve(peertableRepo, 'room', 'client.mjs')] }
|
|
25
|
+
const current = config?.mcpServers?.room
|
|
26
|
+
if (current && typeof current === 'object' && !Array.isArray(current)
|
|
27
|
+
&& Object.keys(current).sort().join(',') === 'args,command'
|
|
28
|
+
&& current.command === expected.command
|
|
29
|
+
&& Array.isArray(current.args)
|
|
30
|
+
&& current.args.length === 1
|
|
31
|
+
&& current.args[0] === expected.args[0]) process.exit(0)
|
|
32
|
+
|
|
33
|
+
if (ownership !== 'managed')
|
|
34
|
+
fail('SEAT_ROOM_MCP_STALE', '既存.mcp.jsonのroom serverをcurrent-tree clientへmergeする必要がある')
|
|
35
|
+
|
|
36
|
+
config.mcpServers ??= {}
|
|
37
|
+
config.mcpServers.room = expected
|
|
38
|
+
const temporary = join(project, `.mcp.json.${process.pid}.tmp`)
|
|
39
|
+
let fd
|
|
40
|
+
try {
|
|
41
|
+
const mode = statSync(file).mode & 0o777
|
|
42
|
+
fd = openSync(temporary, 'wx', mode)
|
|
43
|
+
writeFileSync(fd, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
44
|
+
fsyncSync(fd)
|
|
45
|
+
closeSync(fd)
|
|
46
|
+
fd = undefined
|
|
47
|
+
renameSync(temporary, file)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (fd !== undefined) closeSync(fd)
|
|
50
|
+
try { unlinkSync(temporary) } catch {}
|
|
51
|
+
fail('SEAT_ROOM_MCP_UPDATE_FAILED', error.message)
|
|
52
|
+
}
|