peertable 0.6.1 → 0.7.1

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.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +126 -122
  3. package/README.md +162 -158
  4. package/package.json +49 -49
  5. package/room/Dockerfile +7 -7
  6. package/room/client.mjs +53 -8
  7. package/room/server.mjs +188 -12
  8. package/skill/02_models.snapshot.md +103 -103
  9. package/skill/SKILL.md +264 -259
  10. package/skill/scripts/alarm-set.sh +0 -0
  11. package/skill/scripts/archive-room-log.py +0 -0
  12. package/skill/scripts/bridge-record-live.mjs +0 -0
  13. package/skill/scripts/change-effort.sh +0 -0
  14. package/skill/scripts/change-seat.sh +0 -0
  15. package/skill/scripts/codex-parent-watch.sh +0 -0
  16. package/skill/scripts/doctor.sh +0 -0
  17. package/skill/scripts/ensure-bridge.sh +0 -0
  18. package/skill/scripts/ensure-codex-room-mcp.mjs +0 -0
  19. package/skill/scripts/ensure-room-mcp.mjs +3 -7
  20. package/skill/scripts/external-pane.mjs +0 -0
  21. package/skill/scripts/kickoff-gate.mjs +82 -0
  22. package/skill/scripts/launch-seat.sh +0 -0
  23. package/skill/scripts/leave-seat.sh +0 -0
  24. package/skill/scripts/make-plan-input.mjs +0 -0
  25. package/skill/scripts/parent-join.sh +0 -0
  26. package/skill/scripts/remove-managed-room-mcp.mjs +70 -0
  27. package/skill/scripts/resolve-seat-placement.mjs +0 -0
  28. package/skill/scripts/resume.sh +234 -0
  29. package/skill/scripts/room-mcp-config.mjs +24 -0
  30. package/skill/scripts/seat-status-bridge.mjs +27 -0
  31. package/skill/scripts/set-mission.sh +0 -0
  32. package/skill/scripts/setup.sh +0 -0
  33. package/skill/scripts/teardown.sh +7 -2
  34. package/skill/scripts/tmux-at.bash +10 -10
  35. package/skill/scripts/upgrade-team-assets.sh +59 -7
  36. package/skill/scripts/wakeup-bridge.mjs +71 -4
  37. package/skill/templates/charter.md +20 -20
  38. package/skill/templates/mcp.json +5 -5
  39. package/skill/templates/member-standalone.md +60 -58
  40. package/skill/templates/member.md +149 -147
  41. package/skill/templates/parent.md +143 -142
  42. package/skill/templates/tasks.md +8 -8
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -4,6 +4,7 @@ import {
4
4
  closeSync, fsyncSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
5
5
  } from 'node:fs'
6
6
  import { join, resolve } from 'node:path'
7
+ import { expectedRoomMcp, isExpectedRoomMcp } from './room-mcp-config.mjs'
7
8
 
8
9
  const fail = (code, message) => {
9
10
  process.stderr.write(`${code}: ${message}\n`)
@@ -21,14 +22,9 @@ try { config = JSON.parse(readFileSync(file, 'utf8')) } catch {
21
22
  }
22
23
  if (!config || typeof config !== 'object' || Array.isArray(config))
23
24
  fail('SEAT_ROOM_MCP_INVALID', `${file} のrootがobjectでない`)
24
- const expected = { command: 'node', args: [resolve(peertableRepo, 'room', 'client.mjs')] }
25
+ const expected = expectedRoomMcp(peertableRepo)
25
26
  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)
27
+ if (isExpectedRoomMcp(current, expected)) process.exit(0)
32
28
 
33
29
  if (ownership !== 'managed')
34
30
  fail('SEAT_ROOM_MCP_STALE', '既存.mcp.jsonのroom serverをcurrent-tree clientへmergeする必要がある')
File without changes
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // 円卓開始ゲート(決定104)。kickoff を「依頼済み」と扱ってよいかを機械判定する。
3
+ // 3条件(①対象席の実効稼働状態が fresh ②kickoff message の delivered receipt ③対象席の引受発言)が
4
+ // 全席で成立するまで pending。親の推測・経過時間・room 保存成功による稼働判定を置き換える。
5
+ //
6
+ // usage: kickoff-gate.mjs <project_dir> --seq <kickoff_seq> --seats <a,b,c> [--ack-regex <re>] [--json]
7
+ // 終了コード: 0 = active / 3 = pending / 1 = 判定不能(room へ到達できない等)
8
+ import { readFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ const args = process.argv.slice(2)
12
+ const proj = args[0]
13
+ const opt = (name) => {
14
+ const i = args.indexOf(name)
15
+ return i >= 0 ? args[i + 1] : undefined
16
+ }
17
+ const seq = Number(opt('--seq'))
18
+ const seats = (opt('--seats') ?? '').split(',').map(s => s.trim()).filter(Boolean)
19
+ const ackRe = new RegExp(opt('--ack-regex') ?? '引受|着手|claim', 'iu')
20
+ const asJson = args.includes('--json')
21
+ if (!proj || !Number.isSafeInteger(seq) || seq <= 0 || seats.length === 0) {
22
+ console.error('usage: kickoff-gate.mjs <project_dir> --seq <kickoff_seq> --seats <a,b,c> [--ack-regex <re>] [--json]')
23
+ process.exit(1)
24
+ }
25
+
26
+ const { room, server_url: url } = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
27
+ const api = p => `${url}/api/${encodeURIComponent(room)}/${p}`
28
+ const get = async (p) => {
29
+ const res = await fetch(api(p))
30
+ if (!res.ok) throw new Error(`GET ${p} -> HTTP ${res.status}`)
31
+ return res.json()
32
+ }
33
+
34
+ let membersBody, deliveryBody, messagesBody
35
+ try {
36
+ ;[membersBody, deliveryBody, messagesBody] = await Promise.all([
37
+ get('members'), get(`deliveries?seq=${seq}`), get(`messages?since=${seq}`),
38
+ ])
39
+ } catch (error) {
40
+ console.error(`KICKOFF_GATE_UNREACHABLE: ${error.message}`)
41
+ process.exit(1)
42
+ }
43
+ const members = new Map(membersBody.members.map(m => [m.name, m]))
44
+ if (membersBody.members.length && membersBody.members[0].status_effective === undefined) {
45
+ console.error('KICKOFF_GATE_SERVER_TOO_OLD: server が実効稼働状態を返さない版。判定できない')
46
+ process.exit(1)
47
+ }
48
+
49
+ const report = {}
50
+ let allPass = true
51
+ for (const seat of seats) {
52
+ const member = members.get(seat)
53
+ const fresh = member?.status_reason === 'fresh' && member.status_effective !== 'dead'
54
+ const delivery = deliveryBody.delivery?.[seat]
55
+ const delivered = delivery?.state === 'delivered'
56
+ const ack = messagesBody.messages.find(m => m.from === seat && m.seq > seq && ackRe.test(m.body ?? ''))
57
+ const pass = fresh && delivered && Boolean(ack)
58
+ allPass = allPass && pass
59
+ report[seat] = {
60
+ pass,
61
+ status: member ? { effective: member.status_effective, reason: member.status_reason } : { effective: null, reason: 'member_not_found' },
62
+ delivery: delivery ?? { state: 'unknown' },
63
+ ack: ack ? { seq: ack.seq, body: String(ack.body).slice(0, 80) } : null,
64
+ }
65
+ }
66
+
67
+ const state = allPass ? 'active' : 'pending'
68
+ if (asJson) {
69
+ console.log(JSON.stringify({ schema: 'peertable.kickoff_gate.v1', room, kickoff_seq: seq, state, seats: report }))
70
+ } else {
71
+ console.log(`kickoff-gate: ${state}(room=${room} kickoff seq=${seq})`)
72
+ for (const [seat, r] of Object.entries(report)) {
73
+ const parts = [
74
+ `状態=${r.status.effective ?? '不在'}(${r.status.reason})`,
75
+ `配達=${r.delivery.state}${r.delivery.reason ? `(${r.delivery.reason})` : ''}`,
76
+ `引受=${r.ack ? `seq ${r.ack.seq}` : 'なし'}`,
77
+ ]
78
+ console.log(` ${r.pass ? 'OK' : 'NG'} ${seat}: ${parts.join(' / ')}`)
79
+ }
80
+ if (state === 'pending') console.log(' 3条件が揃うまで円卓taskは依頼済みと扱わないこと(決定104)')
81
+ }
82
+ process.exit(state === 'active' ? 0 : 3)
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ closeSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
4
+ } from 'node:fs'
5
+ import { basename, dirname, join, resolve } from 'node:path'
6
+ import { randomBytes } from 'node:crypto'
7
+ import { isPeertableRoomMcp } from './room-mcp-config.mjs'
8
+
9
+ const fail = (code, detail) => {
10
+ process.stderr.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
11
+ result: 'rejected', code, detail })}\n`)
12
+ process.exit(1)
13
+ }
14
+
15
+ const [project] = process.argv.slice(2)
16
+ if (!project) fail('PEERTABLE_MANAGED_MCP_REMOVE_ARGS_INVALID', '<project>')
17
+ const file = resolve(project, '.mcp.json')
18
+ let stat
19
+ try { stat = lstatSync(file) } catch (error) {
20
+ if (error?.code === 'ENOENT') {
21
+ process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
22
+ result: 'ok', action: 'already-absent' })}\n`)
23
+ process.exit(0)
24
+ }
25
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_UNREADABLE', error.message)
26
+ }
27
+ if (stat.isSymbolicLink() || !stat.isFile())
28
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_UNSAFE', `${file} がsymlinkまたはregular fileでない`)
29
+
30
+ let config
31
+ try { config = JSON.parse(readFileSync(file, 'utf8')) } catch (error) {
32
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_INVALID', `${file}: JSONを読めない: ${error.message}`)
33
+ }
34
+ if (!config || typeof config !== 'object' || Array.isArray(config)
35
+ || !config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers)) {
36
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_INVALID', `${file}: root/mcpServersがobjectでない`)
37
+ }
38
+ if (config.mcpServers.room === undefined) {
39
+ process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
40
+ result: 'ok', action: 'room-already-absent' })}\n`)
41
+ process.exit(0)
42
+ }
43
+ if (!isPeertableRoomMcp(config.mcpServers.room))
44
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_CONFLICT', `${file}: room blockがPeertable所有形でない`)
45
+
46
+ delete config.mcpServers.room
47
+ if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers
48
+ if (Object.keys(config).length === 0) {
49
+ unlinkSync(file)
50
+ process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
51
+ result: 'ok', action: 'file-deleted' })}\n`)
52
+ process.exit(0)
53
+ }
54
+
55
+ const temporary = join(dirname(file), `.${basename(file)}.teardown-${process.pid}-${randomBytes(6).toString('hex')}.tmp`)
56
+ let fd
57
+ try {
58
+ fd = openSync(temporary, 'wx', stat.mode & 0o777)
59
+ writeFileSync(fd, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
60
+ fsyncSync(fd)
61
+ closeSync(fd)
62
+ fd = undefined
63
+ renameSync(temporary, file)
64
+ } catch (error) {
65
+ if (fd !== undefined) closeSync(fd)
66
+ try { unlinkSync(temporary) } catch {}
67
+ fail('PEERTABLE_MANAGED_MCP_REMOVE_FAILED', error.message)
68
+ }
69
+ process.stdout.write(`${JSON.stringify({ schema: 'peertable.managed_room_mcp_remove_result.v1',
70
+ result: 'ok', action: 'room-removed-file-preserved' })}\n`)
File without changes
@@ -0,0 +1,234 @@
1
+ #!/bin/bash
2
+ # 既存 room の正規 resume(決定105)。過去ログを残した同じ room を、現行工程へ接続し直して再稼働させる。
3
+ # usage: resume.sh <project_dir> [--plan <plan_key>] [--phase <id>]... [--no-probe]
4
+ #
5
+ # 一回で行うこと:
6
+ # 1. 既存 .team/ と room の確認(無ければ setup.sh を案内して止まる)
7
+ # 2. --plan 指定時、setup-state.json / roles/member.md / 外部ペインを現行 plan へ再束縛
8
+ # 3. 死んだ bridge 記録(pid 不一致・停止)の除去
9
+ # 4. 台帳(room member 行)から現行メンバー構成を読み、席が死んでいるものだけ launch-seat.sh で再起動
10
+ # (credential の再生成・本人性の再記録は launch-seat.sh が持つ)
11
+ # 5. alarm / seat-status / wakeup bridge の再起動(ensure-bridge.sh)
12
+ # 6. fresh heartbeat の読み戻し(server 実効状態が fresh になるまで待つ)
13
+ # 7. テスト DM の配送 receipt 確認(delivered になるまで待つ。--no-probe で省略)
14
+ #
15
+ # 手書きのメンバー一覧・個別再起動 script には依存しない。親の再着卓(parent-join / 番犬)は別手順
16
+ #(SKILL.md「親の再着卓」)——resume は席と bridge の面だけを持つ。
17
+ set -euo pipefail
18
+
19
+ proj="${1:-}"
20
+ [ -n "$proj" ] || { echo "usage: resume.sh <project_dir> [--plan <plan_key>] [--phase <id>]... [--no-probe]" >&2; exit 1; }
21
+ shift
22
+ plan=""
23
+ phases=()
24
+ probe=true
25
+ while [ $# -gt 0 ]; do
26
+ case "$1" in
27
+ --plan) plan="${2:-}"; shift 2 ;;
28
+ --phase) [ -n "${2:-}" ] || { echo "ERROR: --phase には phase id が要る" >&2; exit 1; }; phases+=("$2"); shift 2 ;;
29
+ --no-probe) probe=false; shift ;;
30
+ *) echo "ERROR: 未知の引数: $1" >&2; exit 1 ;;
31
+ esac
32
+ done
33
+ proj=$(cd "$proj" && pwd)
34
+ script_dir=$(cd "$(dirname "$0")" && pwd -P)
35
+ repo="${PEERTABLE_REPO:-$(cd "$script_dir/../.." && pwd -P)}"
36
+ [ -f "$repo/room/client.mjs" ] || { echo "RESUME_PEERTABLE_TREE_UNRESOLVED: $repo が peertable tree でない" >&2; exit 1; }
37
+ setup="$proj/.team/setup-state.json"
38
+ if [ ! -f "$setup" ]; then
39
+ echo "RESUME_NOT_SET_UP: $setup が無い。新規の卓は setup.sh で立てること" >&2
40
+ exit 1
41
+ fi
42
+
43
+ # resumeは同じroomを現行Peertable treeへ再接続する入口である。席が全て生存していて再起動0件でも、
44
+ # Peertable所有のgenerated assetとroot room MCPを先に現行版へ更新する。
45
+ "$script_dir/upgrade-team-assets.sh" "$proj"
46
+
47
+ seats_file="$proj/.team/resume-seats.json"
48
+
49
+ # Phase A: 到達確認・plan 再束縛・死記録掃除・再起動対象の抽出(node が JSON を書き出す)
50
+ RESUME_PROJ="$proj" RESUME_REPO="$repo" RESUME_PLAN="$plan" RESUME_PHASES="${phases[*]:-}" \
51
+ RESUME_SEATS_FILE="$seats_file" node --input-type=module <<'NODE'
52
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
53
+ import { execFileSync } from 'node:child_process'
54
+ import { join } from 'node:path'
55
+ import { pathToFileURL } from 'node:url'
56
+
57
+ const proj = process.env.RESUME_PROJ
58
+ const repo = process.env.RESUME_REPO
59
+ const team = join(proj, '.team')
60
+ const scriptDir = join(repo, 'skill', 'scripts')
61
+ const { bridgeRecordLive } = await import(pathToFileURL(join(scriptDir, 'bridge-record-live.mjs')))
62
+ const { resolveSeatObservation, tmuxArgv } = await import(pathToFileURL(join(scriptDir, 'seat-usage.mjs')))
63
+
64
+ const setupPath = join(team, 'setup-state.json')
65
+ const state = JSON.parse(readFileSync(setupPath, 'utf8'))
66
+ const { room, server_url: url } = state
67
+
68
+ // 1. room の存在確認(読み取りは room を作らないので、summary が member 0・seq 0 でも部屋の有無は API 一覧で見る)
69
+ const roomsRes = await fetch(`${url}/api/rooms`).catch((e) => { throw new Error(`RESUME_ROOM_UNREACHABLE: ${url} へ到達できない: ${e.message}`) })
70
+ if (!roomsRes.ok) throw new Error(`RESUME_ROOM_UNREACHABLE: GET /api/rooms -> HTTP ${roomsRes.status}`)
71
+ const { rooms } = await roomsRes.json()
72
+ if (!rooms.includes(room)) throw new Error(`RESUME_ROOM_MISSING: room「${room}」が server に無い。新規は setup.sh で立てること`)
73
+ console.log(`[実施] room 確認: ${room} @ ${url}`)
74
+
75
+ // 2. plan 再束縛(--plan 指定時だけ)
76
+ const plan = process.env.RESUME_PLAN
77
+ const phases = (process.env.RESUME_PHASES ?? '').split(' ').filter(Boolean)
78
+ if (plan) {
79
+ state.mode = 'lattice'
80
+ state.plan_key = plan
81
+ state.phases = phases
82
+ writeFileSync(setupPath, JSON.stringify(state) + '\n')
83
+ const scope = phases.length === 0
84
+ ? 'この卓の claim 範囲は plan 全体(phase 指定なしで立っている)。'
85
+ : `**この卓の claim 範囲は phase ${phases.join(' ')} の task だけ**。範囲外の phase の task は、ready に見えていても取らない。範囲外に手を入れる必要が出たら room へ出して裁定を仰ぐ。`
86
+ const template = readFileSync(join(repo, 'skill', 'templates', 'member.md'), 'utf8')
87
+ writeFileSync(join(team, 'roles', 'member.md'), template.replaceAll('{{PLAN_KEY}}', plan).replaceAll('{{CLAIM_SCOPE}}', scope))
88
+ const publicUrl = process.env.PEERTABLE_PUBLIC_URL ?? state.public_url ?? url
89
+ execFileSync('node', [join(scriptDir, 'external-pane.mjs'), proj, room, publicUrl], { stdio: ['ignore', 'ignore', 'inherit'] })
90
+ console.log(`[実施] plan 再束縛: ${plan}${phases.length ? `(phase ${phases.join(',')})` : ''}`)
91
+ } else {
92
+ console.log(`[スキップ] plan 再束縛(--plan 未指定。現行 ${state.plan_key || state.mode} のまま)`)
93
+ }
94
+
95
+ // 3. 死んだ bridge 記録の除去(生きている記録は触らない。parent-watch.json は cursor 正本なので消さない)
96
+ for (const name of ['wakeup-bridge.json', 'seat-status-bridge.json', 'alarm-bridge.json']) {
97
+ const path = join(team, name)
98
+ if (!existsSync(path)) continue
99
+ let record = null
100
+ try { record = JSON.parse(readFileSync(path, 'utf8')) } catch {}
101
+ if (bridgeRecordLive(record)) { console.log(`[スキップ] ${name}: 生存中(pid ${record.pid})`); continue }
102
+ unlinkSync(path)
103
+ console.log(`[実施] ${name}: 死んだ記録を除去(pid ${record?.pid ?? '不明'})`)
104
+ }
105
+
106
+ // 4. 台帳から再起動対象を抽出。席の生死は tmux session の実在で判定する
107
+ const { members } = await (await fetch(`${url}/api/${encodeURIComponent(room)}/members`)).json()
108
+ const relaunch = []
109
+ const missingRoles = []
110
+ for (const member of members) {
111
+ if (member.delivery?.kind === 'parent_watch') continue
112
+ const harness = member.harness ?? member.vendor
113
+ if (!harness) continue // 素性の無い行(親の手動登録等)は席ではない
114
+ const observation = resolveSeatObservation(member, null)
115
+ let aliveSeat = false
116
+ if (observation) {
117
+ try {
118
+ execFileSync('tmux', tmuxArgv(['has-session', '-t', observation.target], { socket: observation.socket }), { stdio: 'ignore' })
119
+ aliveSeat = true
120
+ } catch {}
121
+ }
122
+ if (aliveSeat) { console.log(`[スキップ] 席 ${member.name}: tmux session 生存中`); continue }
123
+ if (!Array.isArray(member.roles) || member.roles.length === 0) { missingRoles.push(member.name); continue }
124
+ relaunch.push({
125
+ name: member.name, roles: member.roles.join(','), harness,
126
+ model: member.model ?? null, effort: member.effort ?? null, mission: member.mission ?? null,
127
+ })
128
+ }
129
+ if (missingRoles.length) {
130
+ throw new Error(`RESUME_MEMBER_ROLES_MISSING: 役割の無い member は再起動できない: ${missingRoles.join(', ')}。`
131
+ + 'launch-seat.sh --roles で個別に立て直すか、member 行を整備すること')
132
+ }
133
+ writeFileSync(process.env.RESUME_SEATS_FILE, JSON.stringify(relaunch) + '\n')
134
+ console.log(`[実施] 再起動対象の抽出: ${relaunch.length} 席(${relaunch.map(s => s.name).join(', ') || 'なし'})`)
135
+ NODE
136
+
137
+ # Phase B: 席の再起動(credential 再生成・本人性再記録は launch-seat.sh が持つ)。
138
+ # 受け渡しは TSV(mission に tab を含む member は台帳整備の対象であり、ここでは想定しない)
139
+ while IFS=$'\t' read -r name roles harness model effort mission; do
140
+ [ -n "$name" ] || continue
141
+ argv=("$proj" "$name" --roles "$roles" --harness "$harness")
142
+ [ -n "$model" ] && argv+=(--model "$model")
143
+ [ -n "$effort" ] && argv+=(--effort "$effort")
144
+ [ -n "$mission" ] && argv+=(--mission "$mission")
145
+ echo "[実施] 席の再起動: $name(roles=$roles harness=$harness${model:+ model=$model}${effort:+ effort=$effort})"
146
+ env -u PEERTABLE_POST_TOKEN "$script_dir/launch-seat.sh" "${argv[@]}"
147
+ done < <(node -e '
148
+ const seats = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"))
149
+ for (const s of seats) console.log([s.name, s.roles, s.harness, s.model ?? "", s.effort ?? "", s.mission ?? ""].join("\t"))
150
+ ' "$seats_file")
151
+ rm -f "$seats_file"
152
+
153
+ # Phase C: bridge の再起動
154
+ for kind in alarm seat-status wakeup; do
155
+ if "$script_dir/ensure-bridge.sh" "$proj" "$kind"; then
156
+ echo "[実施] ${kind}-bridge: ready"
157
+ else
158
+ echo "[未実施] ${kind}-bridge: 起動を確認できなかった(ログ $proj/.team/${kind}-bridge.log)" >&2
159
+ exit 1
160
+ fi
161
+ done
162
+
163
+ # Phase D: fresh heartbeat の読み戻しと probe DM の配送 receipt 確認
164
+ RESUME_PROJ="$proj" RESUME_PROBE="$probe" RESUME_SCRIPT_DIR="$script_dir" node --input-type=module <<'NODE'
165
+ import { readFileSync } from 'node:fs'
166
+ import { join } from 'node:path'
167
+ import { pathToFileURL } from 'node:url'
168
+
169
+ const proj = process.env.RESUME_PROJ
170
+ const { room, server_url: url } = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
171
+ const api = p => `${url}/api/${encodeURIComponent(room)}/${p}`
172
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
173
+
174
+ // launch-seat.sh 直後の席(tmux session が居る非親 member)が対象
175
+ const { resolvePostToken } = await import(pathToFileURL(join(process.env.RESUME_SCRIPT_DIR, 'seat-usage.mjs')))
176
+
177
+ // 6. fresh heartbeat の読み戻し(seat-status bridge 心拍 30 秒 + 判定余裕)
178
+ const deadline = Date.now() + 90_000
179
+ let pendingSeats = []
180
+ for (;;) {
181
+ const { members, bridges } = await (await fetch(api('members'))).json()
182
+ const seats = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target)
183
+ if (seats.length === 0) {
184
+ console.log('[スキップ] fresh heartbeat 読み戻し(観測対象の席なし)')
185
+ break
186
+ }
187
+ pendingSeats = seats.filter(m => m.status_reason !== 'fresh')
188
+ if (pendingSeats.length === 0) {
189
+ console.log(`[実施] fresh heartbeat 読み戻し: ${seats.length} 席すべて fresh`)
190
+ break
191
+ }
192
+ if (Date.now() > deadline) {
193
+ console.error(`RESUME_HEARTBEAT_STALE: fresh にならない席: ${pendingSeats.map(m => `${m.name}(${m.status_reason})`).join(', ') || '(席なし)'}`
194
+ + `(bridges: seat_status=${bridges?.seat_status?.state} wakeup=${bridges?.wakeup?.state})`)
195
+ process.exit(1)
196
+ }
197
+ await sleep(3000)
198
+ }
199
+
200
+ // 7. probe DM の配送 receipt 確認
201
+ if (process.env.RESUME_PROBE !== 'true') {
202
+ console.log('[スキップ] probe DM(--no-probe 指定)')
203
+ process.exit(0)
204
+ }
205
+ const token = resolvePostToken(process.env)
206
+ const headers = { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) }
207
+ const { members } = await (await fetch(api('members'))).json()
208
+ const targets = members.filter(m => m.delivery?.kind !== 'parent_watch' && (m.harness ?? m.vendor) && m.observe?.tmux_target).map(m => m.name)
209
+ if (!targets.length) { console.log('[スキップ] probe DM(配達対象の席なし)'); process.exit(0) }
210
+ const res = await fetch(api('messages'), {
211
+ method: 'POST', headers,
212
+ body: JSON.stringify({ from: 'resume', to: targets.length === 1 ? targets[0] : targets,
213
+ body: '[resume-probe] 配達経路の確認。応答不要・読み流してよい。' }),
214
+ })
215
+ const saved = await res.json()
216
+ if (!res.ok) { console.error(`RESUME_PROBE_POST_FAILED: ${JSON.stringify(saved)}`); process.exit(1) }
217
+ console.log(`[実施] probe DM 投稿: seq ${saved.seq} → ${targets.join(', ')}`)
218
+ const probeDeadline = Date.now() + 120_000
219
+ for (;;) {
220
+ const { delivery } = await (await fetch(api(`deliveries?seq=${saved.seq}`))).json()
221
+ const undelivered = targets.filter(t => delivery?.[t]?.state !== 'delivered')
222
+ if (undelivered.length === 0) {
223
+ console.log('[実施] probe DM の配送 receipt: 全席 delivered')
224
+ break
225
+ }
226
+ if (Date.now() > probeDeadline) {
227
+ console.error(`RESUME_PROBE_UNDELIVERED: delivered にならない席: ${undelivered.map(t => `${t}=${delivery?.[t]?.state ?? 'unknown'}${delivery?.[t]?.reason ? `(${delivery[t].reason})` : ''}`).join(', ')}`
228
+ + '(Grok 席は idle 待ちで遅れることがある。.team/wakeup-bridge.log を確認すること)')
229
+ process.exit(1)
230
+ }
231
+ await sleep(3000)
232
+ }
233
+ console.log('resume 完了。親の再着卓(parent-join / 番犬)は SKILL.md「親の再着卓」の手順で別途行うこと')
234
+ NODE
@@ -0,0 +1,24 @@
1
+ import { resolve } from 'node:path'
2
+
3
+ export function expectedRoomMcp(peertableRepo) {
4
+ return { command: 'node', args: [resolve(peertableRepo, 'room', 'client.mjs')] }
5
+ }
6
+
7
+ export function isExpectedRoomMcp(current, expected) {
8
+ return Boolean(current && typeof current === 'object' && !Array.isArray(current)
9
+ && Object.keys(current).sort().join(',') === 'args,command'
10
+ && current.command === expected.command
11
+ && Array.isArray(current.args)
12
+ && current.args.length === 1
13
+ && current.args[0] === expected.args[0])
14
+ }
15
+
16
+ export function isPeertableRoomMcp(current) {
17
+ return Boolean(current && typeof current === 'object' && !Array.isArray(current)
18
+ && Object.keys(current).sort().join(',') === 'args,command'
19
+ && current.command === 'node'
20
+ && Array.isArray(current.args)
21
+ && current.args.length === 1
22
+ && typeof current.args[0] === 'string'
23
+ && /(?:^|[\\/])room[\\/]client\.mjs$/u.test(current.args[0]))
24
+ }
@@ -179,6 +179,32 @@ async function nudgeIfDropped(name, busySince) {
179
179
  }
180
180
  }
181
181
 
182
+ // bridge 心拍(決定103)。server の bridge 台帳へ 30 秒ごとに送る。途絶=status_bridge_down、
183
+ // 403=server 側が bridge_auth_failed として観測する。旧 server(404)へは送り続けない
184
+ let lastBridgeBeatAt = 0
185
+ let bridgeBeatSupported = null
186
+ async function beatBridge() {
187
+ const now = Date.now()
188
+ if (bridgeBeatSupported === false || now - lastBridgeBeatAt < HEARTBEAT_MS) return
189
+ try {
190
+ const res = await fetch(`${url}/api/${encodeURIComponent(room)}/bridges`, {
191
+ method: 'POST',
192
+ headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) },
193
+ body: JSON.stringify({ kind: 'seat_status', pid: process.pid, state: 'running' }),
194
+ })
195
+ if (res.status === 404) {
196
+ bridgeBeatSupported = false
197
+ console.error('seat-status-bridge: server が bridge 台帳を持たない版(404)。心拍送信を止める')
198
+ return
199
+ }
200
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
201
+ bridgeBeatSupported = true
202
+ lastBridgeBeatAt = now
203
+ } catch (e) {
204
+ console.error(`seat-status-bridge: bridge心拍の送信に失敗: ${e.message}`)
205
+ }
206
+ }
207
+
182
208
  const last = new Map() // name -> { status, at }
183
209
  let supported = null // server が status を保持する版か(未判定は null)
184
210
  const tokenBucket = value => value === null ? null : Math.floor(value / 1_000)
@@ -282,6 +308,7 @@ let failedTicks = 0
282
308
  let provenWritable = false
283
309
 
284
310
  async function guardedTick() {
311
+ await beatBridge()
285
312
  const { attempted, failed } = await tick() ?? NOTHING_ATTEMPTED
286
313
  const decided = decideBridgeContinuation({ attempted, failed, provenWritable, failedTicks, limit: FAILED_TICK_LIMIT })
287
314
  provenWritable = decided.provenWritable
File without changes
File without changes
@@ -44,6 +44,12 @@ skip() { echo "teardown: [スキップ] $*"; }
44
44
  miss() { echo "teardown: [未実施] $*" >&2; fail=1; }
45
45
  yes_() { [ "$1" = "True" ] || [ "$1" = "true" ]; }
46
46
 
47
+ mcp_remove_action=not-managed
48
+ if yes_ "$added_mcp"; then
49
+ mcp_remove_result=$(node "$script_dir/remove-managed-room-mcp.mjs" "$proj") || exit "$?"
50
+ mcp_remove_action=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["action"])' "$mcp_remove_result")
51
+ fi
52
+
47
53
  echo "teardown: mode=${mode}(archive=ログとstoreを残す/purge=痕跡ゼロ)"
48
54
 
49
55
  # ---- ログの控え(archive だけ)。room 自体は残るので、これは repo 側の写し ----
@@ -316,8 +322,7 @@ rm -rf "$proj/.team"
316
322
  did ".team/ 削除"
317
323
 
318
324
  if yes_ "$added_mcp"; then
319
- rm -f "$proj/.mcp.json"
320
- did ".mcp.json 削除"
325
+ did ".mcp.json のPeertable room MCP撤去(${mcp_remove_action})"
321
326
  else
322
327
  skip ".mcp.json(setup が作っていない)"
323
328
  fi
@@ -1,10 +1,10 @@
1
- # sourced by launch-seat / leave-seat / teardown / ensure-bridge / change-seat
2
- # POSIX は tmux -S <sock>。Windows psmux は -L <aiterm-ns>(-S は既定 namespace へ落ちる)。
3
- _peertable_tmux_scripts=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
4
- tmux_at() {
5
- local prefix
6
- prefix=$(node "$_peertable_tmux_scripts/tmux-socket.mjs" --prefix) || return 1
7
- # shellcheck disable=SC2206
8
- local -a conn=($prefix)
9
- command tmux "${conn[@]}" "$@"
10
- }
1
+ # sourced by launch-seat / leave-seat / teardown / ensure-bridge / change-seat
2
+ # POSIX は tmux -S <sock>。Windows psmux は -L <aiterm-ns>(-S は既定 namespace へ落ちる)。
3
+ _peertable_tmux_scripts=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
4
+ tmux_at() {
5
+ local prefix
6
+ prefix=$(node "$_peertable_tmux_scripts/tmux-socket.mjs" --prefix) || return 1
7
+ # shellcheck disable=SC2206
8
+ local -a conn=($prefix)
9
+ command tmux "${conn[@]}" "$@"
10
+ }