peertable 0.8.55 → 0.8.57

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 (75) hide show
  1. package/README.ja.md +32 -33
  2. package/README.md +36 -37
  3. package/package.json +3 -1
  4. package/room/client.mjs +24 -33
  5. package/room/server.mjs +1 -1
  6. package/skill/SKILL.md +69 -94
  7. package/skill/scripts/aiterm-client.mjs +48 -0
  8. package/skill/scripts/aiterm-deliver.mjs +3 -1
  9. package/skill/scripts/change-seat.mjs +86 -0
  10. package/skill/scripts/change-seat.sh +3 -276
  11. package/skill/scripts/change-seat.test.mjs +129 -0
  12. package/skill/scripts/cli.mjs +77 -0
  13. package/skill/scripts/doctor.mjs +53 -0
  14. package/skill/scripts/doctor.sh +2 -213
  15. package/skill/scripts/ensure-all-bridges.mjs +42 -0
  16. package/skill/scripts/ensure-all-bridges.sh +3 -80
  17. package/skill/scripts/ensure-bridge.mjs +11 -0
  18. package/skill/scripts/ensure-bridge.sh +2 -95
  19. package/skill/scripts/ensure-codex-room-mcp.mjs +1 -1
  20. package/skill/scripts/ensure-project-runtime.mjs +85 -0
  21. package/skill/scripts/ensure-project-runtime.sh +2 -11
  22. package/skill/scripts/ensure-project-runtime.test.mjs +40 -0
  23. package/skill/scripts/install-skill.mjs +89 -0
  24. package/skill/scripts/install-skill.test.mjs +84 -0
  25. package/skill/scripts/launch-seat.mjs +163 -0
  26. package/skill/scripts/launch-seat.sh +3 -797
  27. package/skill/scripts/launch-seat.test.mjs +174 -0
  28. package/skill/scripts/leave-seat.mjs +44 -0
  29. package/skill/scripts/leave-seat.sh +3 -92
  30. package/skill/scripts/leave-seat.test.mjs +64 -0
  31. package/skill/scripts/legacy-entry.mjs +33 -0
  32. package/skill/scripts/platform/windows/resolve-lattice-command.mjs +12 -1
  33. package/skill/scripts/project-runtime.mjs +95 -0
  34. package/skill/scripts/project-runtime.test.mjs +29 -0
  35. package/skill/scripts/project-scaffold.mjs +179 -0
  36. package/skill/scripts/project-scaffold.test.mjs +129 -0
  37. package/skill/scripts/refresh-seat-identity.mjs +11 -8
  38. package/skill/scripts/remove-managed-room-mcp.mjs +9 -4
  39. package/skill/scripts/resume.sh +2 -225
  40. package/skill/scripts/room-api.mjs +28 -0
  41. package/skill/scripts/room-public-session.test.mjs +73 -0
  42. package/skill/scripts/runtime-contract.test.mjs +13 -146
  43. package/skill/scripts/runtime-launch-command.mjs +19 -0
  44. package/skill/scripts/runtime-launch-command.test.mjs +32 -0
  45. package/skill/scripts/seat-approval.mjs +24 -0
  46. package/skill/scripts/seat-approval.test.mjs +43 -0
  47. package/skill/scripts/seat-identity.mjs +2 -71
  48. package/skill/scripts/seat-launch-phase.mjs +11 -0
  49. package/skill/scripts/seat-observer.mjs +54 -0
  50. package/skill/scripts/seat-observer.test.mjs +58 -0
  51. package/skill/scripts/seat-session.mjs +17 -0
  52. package/skill/scripts/seat-status-bridge.mjs +33 -206
  53. package/skill/scripts/seat-usage.mjs +3 -334
  54. package/skill/scripts/setup.sh +3 -215
  55. package/skill/scripts/teardown.mjs +113 -0
  56. package/skill/scripts/teardown.sh +3 -410
  57. package/skill/scripts/teardown.test.mjs +62 -0
  58. package/skill/scripts/upgrade-team-assets.mjs +268 -0
  59. package/skill/scripts/upgrade-team-assets.sh +2 -282
  60. package/skill/scripts/wakeup-bridge.mjs +26 -32
  61. package/skill/scripts/wakeup-delivery.mjs +4 -14
  62. package/skill/templates/charter.md +1 -1
  63. package/skill/templates/member-standalone.md +1 -1
  64. package/skill/templates/member.md +3 -3
  65. package/skill/scripts/agent-pane-status.mjs +0 -6
  66. package/skill/scripts/aiterm-configure.mjs +0 -31
  67. package/skill/scripts/aiterm-launch.mjs +0 -64
  68. package/skill/scripts/aiterm-send.mjs +0 -22
  69. package/skill/scripts/claude-dialog.mjs +0 -26
  70. package/skill/scripts/codex-dialog.mjs +0 -67
  71. package/skill/scripts/platform/windows/build-bridge-command.mjs +0 -52
  72. package/skill/scripts/seat-input.mjs +0 -36
  73. package/skill/scripts/tmux-at.bash +0 -22
  74. package/skill/scripts/tmux-socket.mjs +0 -19
  75. package/skill/scripts/vendors/grok/pane-status.mjs +0 -30
@@ -1,215 +1,4 @@
1
1
  #!/bin/bash
2
- # 卓の健全性を機械判定して表示し、--repair 指定時だけ死んでいるブリッジを立て直す。
3
- # usage: doctor.sh <project_dir> [--repair]
4
- #
5
- # 判定するのは5点だけ:
6
- # 1. room サーバー到達性(GET /api/<room>/summary)
7
- # 2. 台帳(room member 行)の素性・本人性の完全性
8
- # 3. 各席の tmux セッション(peer-<name>)の存在と、席file の pid+lstart による本人性
9
- # 4. 2ブリッジ(wakeup / seat-status)の record 生存と、record にある識別情報だけで
10
- # 判定できる範囲の本人性・鮮度(判定できないものは「判定不能」と正直に出す。偽の生存判定を作らない)
11
- # 5. Lattice 併用モード(mode=lattice)なら `lattice status --json` の state / active_runs
12
- #
13
- # 各行は OK / NG / REPAIRED / 判定不能 のいずれかで始まる。--repair は NG のブリッジだけ
14
- # ensure-bridge.sh で立て直す。**席の再起動はしない**——席は人の判断が要るので、NG 表示に
15
- # launch-seat.sh を促す一言を添えるだけに留める。
16
- #
17
- # 終了コード: 全 OK(判定不能を含む)= 0、NG が1件でもあれば 1(--repair で全部直れば 0)。
2
+ # 既存の引数を共通の製品入口へ渡す。
18
3
  set -euo pipefail
19
-
20
- proj="${1:-}"
21
- repair=false
22
- if [ "${2:-}" = "--repair" ]; then repair=true; fi
23
- if [ -z "$proj" ]; then
24
- echo "usage: doctor.sh <project_dir> [--repair]" >&2
25
- exit 1
26
- fi
27
- proj=$(cd "$proj" && pwd)
28
- script_dir=$(cd "$(dirname "$0")" && pwd)
29
- setup="$proj/.team/setup-state.json"
30
- if [ ! -f "$setup" ]; then
31
- echo "NG .team/setup-state.json が無い(${proj} は peertable setup 済みか確認せよ)" >&2
32
- exit 1
33
- fi
34
-
35
- DOCTOR_PROJ="$proj" DOCTOR_SCRIPT_DIR="$script_dir" DOCTOR_REPAIR="$repair" \
36
- node --input-type=module <<'NODE'
37
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
38
- import { execFileSync, spawnSync } from 'node:child_process'
39
- import { join } from 'node:path'
40
- import { pathToFileURL } from 'node:url'
41
-
42
- const proj = process.env.DOCTOR_PROJ
43
- const scriptDir = process.env.DOCTOR_SCRIPT_DIR
44
- const repair = process.env.DOCTOR_REPAIR === 'true'
45
- const team = join(proj, '.team')
46
-
47
- const { bridgeRecordLive } = await import(pathToFileURL(join(scriptDir, 'bridge-record-live.mjs')))
48
- const { observePidCommand } = await import(pathToFileURL(join(scriptDir, 'refresh-seat-identity.mjs')))
49
- const { resolveLatticeInvocation, resolveTmuxSocket, tmuxArgv } = await import(pathToFileURL(join(scriptDir, 'seat-usage.mjs')))
50
-
51
- let ng = 0
52
- function line(level, text) {
53
- console.log(`${level} ${text}`)
54
- if (level === 'NG') ng = 1
55
- }
56
-
57
- const setup = JSON.parse(readFileSync(join(team, 'setup-state.json'), 'utf8'))
58
- const { room, server_url: url, mode } = setup
59
-
60
- // 1. room サーバー到達性
61
- let summary = null
62
- try {
63
- const res = await fetch(`${url}/api/${encodeURIComponent(room)}/summary`)
64
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
65
- summary = await res.json()
66
- line('OK', `room 到達: ${url} room=${room} seq=${summary.seq} member_count=${summary.member_count}`)
67
- } catch (error) {
68
- line('NG', `room 到達不可: ${url} room=${room}(${error.message})`)
69
- }
70
-
71
- // members は以降の突合・席チェックにも使う
72
- let members = []
73
- let membersOk = false
74
- try {
75
- const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
76
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
77
- const body = await res.json()
78
- members = Array.isArray(body.members) ? body.members : []
79
- membersOk = true
80
- } catch (error) {
81
- line('NG', `room members を取得できない(${error.message})`)
82
- }
83
-
84
- // 2. 台帳の完全性。tmux 席を持つ member(親以外)は本人性欄(pid 等)も台帳に載っている
85
- // はず(席file は 2026-08-22 廃止・正本は member 行だけ)
86
- function hasDescriptor(member) {
87
- return Boolean(member?.observe && typeof member.observe === 'object'
88
- && typeof member.observe.tmux_target === 'string' && member.observe.tmux_target.length > 0)
89
- }
90
- const seatRows = new Map()
91
- if (membersOk) {
92
- const seated = members.filter(hasDescriptor)
93
- const missingIdentity = seated.filter(m => !Number.isSafeInteger(m.pid) || !m.started_identity || !m.argv_digest)
94
- for (const m of seated) seatRows.set(m.name, m)
95
- if (missingIdentity.length === 0) {
96
- line('OK', `台帳の member 行に素性・本人性が揃っている(${seated.length} 席)`)
97
- } else {
98
- line('NG', `台帳の本人性欄が欠けている member: ${missingIdentity.map(m => m.name).join(',')}`)
99
- }
100
- }
101
-
102
- // 3. 各席の tmux セッション(peer-<name>)の存在と、台帳の pid+lstart による本人性
103
- for (const [name, seat] of seatRows) {
104
- const member = seat
105
- const socket = member?.observe?.tmux_socket || resolveTmuxSocket(process.env).socket
106
- const target = member?.observe?.tmux_target || `peer-${name}`
107
- let sessionExists = false
108
- try {
109
- execFileSync('tmux', tmuxArgv(['has-session', '-t', target], { socket }), { stdio: 'ignore' })
110
- sessionExists = true
111
- } catch { /* has-session は非ゼロ終了で無しを表す */ }
112
- if (!sessionExists) {
113
- line('NG', `席 ${name}: tmux セッション ${target} が無い(launch-seat.sh で立て直す)`)
114
- continue
115
- }
116
- let identity = null
117
- let identityError = null
118
- try { identity = observePidCommand(seat.pid) } catch (error) { identityError = error }
119
- if (identityError) {
120
- line('NG', `席 ${name}: pid ${seat.pid} を観測できない(${identityError.message})(launch-seat.sh で立て直す)`)
121
- continue
122
- }
123
- if (identity.started_identity !== seat.started_identity) {
124
- line('NG', `席 ${name}: pid ${seat.pid} は再利用されている(lstart不一致・本人ではない)(launch-seat.sh で立て直す)`)
125
- continue
126
- }
127
- line('OK', `席 ${name}: tmux ${target} 生存・pid ${seat.pid} 本人性確認`)
128
- }
129
-
130
- // 4. 2ブリッジ(run-bridge は 2026-08-22 退役)。record の形式が違うので判定できる範囲だけ判定する
131
- function judgeWakeup() {
132
- const path = join(team, 'wakeup-bridge.json')
133
- if (!existsSync(path)) return { level: 'NG', text: 'wakeup-bridge: record が無い(起動していない)' }
134
- const record = JSON.parse(readFileSync(path, 'utf8'))
135
- if (bridgeRecordLive(record)) {
136
- const age = Math.round((Date.now() - Date.parse(record.last_progress_at)) / 1000)
137
- return { level: 'OK', text: `wakeup-bridge: 生存 pid=${record.pid} last_progress ${age}秒前` }
138
- }
139
- return { level: 'NG', text: `wakeup-bridge: pid=${record.pid} が死んでいるか last_progress_at が古い` }
140
- }
141
-
142
- function judgeSeatStatusBridge() {
143
- const path = join(team, 'seat-status-bridge.json')
144
- if (!existsSync(path)) return { level: 'NG', text: 'seat-status-bridge: record が無い(起動していない)' }
145
- const record = JSON.parse(readFileSync(path, 'utf8'))
146
- let alive = false
147
- try { process.kill(record.pid, 0); alive = true } catch { /* 死んでいる */ }
148
- if (!alive) return { level: 'NG', text: `seat-status-bridge: pid=${record.pid} が死んでいる` }
149
- return { level: '判定不能', text: `seat-status-bridge: pid=${record.pid} は生存しているが record に lstart が無く本人性を確認できない` }
150
- }
151
-
152
- for (const [name, judge] of [['wakeup', judgeWakeup], ['seat-status', judgeSeatStatusBridge]]) {
153
- let result = judge()
154
- if (result.level === 'NG' && repair) {
155
- const res = spawnSync(join(scriptDir, 'ensure-bridge.sh'), [proj, name], { encoding: 'utf8' })
156
- if (res.status === 0) {
157
- const after = judge()
158
- if (after.level !== 'NG') { line('REPAIRED', `${name}-bridge: 立て直した(${after.text})`); continue }
159
- line('NG', `${name}-bridge: 立て直したが依然として不健全(${after.text})`)
160
- continue
161
- }
162
- const detail = (res.stderr || res.stdout || '').trim().split('\n').slice(-3).join(' / ')
163
- line('NG', `${name}-bridge: 立て直しに失敗(${detail || `exit ${res.status}`})`)
164
- continue
165
- }
166
- line(result.level, result.text)
167
- }
168
-
169
- // 4.5 端末別の装置可用性(silent absence禁止・2026-08-25 オーナー裁定):
170
- // この端末で「動かない」なら動かないと表示する。黙ってMacだけで動く状態を作らない。
171
- {
172
- // job観測(アクティブランプへの預け仕事合成): tmux list-sessions がこの端末で成立するか
173
- try {
174
- const probe = spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' })
175
- if (probe.error) throw probe.error
176
- line('OK', `job観測: tmux list-sessions 実行可(ランプ合成は有効。ジョブは席のシェルからtmuxセッションで走らせる——名前は自由)`)
177
- } catch (error) {
178
- line('NG', `job観測: tmux list-sessions が失敗——預け仕事のランプ合成はこの端末で無効(${String(error.message ?? error).split('\n')[0]})`)
179
- }
180
- // env帰属(2026-08-26 オーナー裁定: 帰属は規約名でなくOSの環境変数継承): この端末のtmux/psmuxが
181
- // update-environment と show-environment を持つか。持たない実装(psmuxの互換欠け等)では
182
- // 名前fallback(peer-<name>- 前置)だけで動くので、その事実を機械判定で出す。
183
- try {
184
- const opts = execFileSync('tmux', tmuxArgv(['show-options', '-g', 'update-environment'], { socket: resolveTmuxSocket(process.env).socket }), { encoding: 'utf8' })
185
- if (/PEERTABLE_MEMBER/.test(opts)) line('OK', 'env帰属: update-environment に PEERTABLE_MEMBER が載っている(新規セッションへ持ち主が自動継承される)')
186
- else line('NG', 'env帰属: update-environment に PEERTABLE_MEMBER が無い——launch-seat をこの版で一度通すと設定される。それまで帰属は peer-<name>- 前置の名前fallbackのみ')
187
- } catch (error) {
188
- line('NG', `env帰属: show-options が失敗——この端末のtmux/psmuxはenv帰属を判定できず、名前fallbackのみで動作(${String(error.message ?? error).split('\n')[0]})`)
189
- }
190
- // 読了ack: server members に read_seq 欄が観測できるか(席が一度もackしていない間は判定できない)
191
- try {
192
- const res = JSON.parse(execFileSync('curl', ['-s', `${url}/api/${encodeURIComponent(room)}/members`], { encoding: 'utf8' }))
193
- const withAck = (res.members ?? []).filter(m => m.read_seq !== undefined).length
194
- if (withAck > 0) line('OK', `読了ack: server対応を確認(read_seq保持 ${withAck} 席)`)
195
- else line('判定不能', '読了ack: read_seq を持つ席がまだ無い(旧serverか、席が一度も read_unread→post していないか区別できない)')
196
- } catch (error) {
197
- line('NG', `読了ack: members を読めない(${String(error.message ?? error).split('\n')[0]})`)
198
- }
199
- }
200
-
201
- // 5. Lattice 併用モードの工程正本
202
- if (mode === 'lattice') {
203
- const latticeCli = process.env.LATTICE_CLI || (typeof setup.lattice_cli === 'string' && setup.lattice_cli) || 'lattice'
204
- try {
205
- const invocation = resolveLatticeInvocation(latticeCli, ['status', '--json'])
206
- const out = execFileSync(invocation.command, invocation.argv, { cwd: proj, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 })
207
- const status = JSON.parse(out)
208
- line('OK', `Lattice: state=${status.state} active_runs=${(status.active_runs ?? []).length}`)
209
- } catch (error) {
210
- line('NG', `Lattice status を取得できない(${String(error.message ?? error).split('\n')[0]})`)
211
- }
212
- }
213
-
214
- process.exit(ng)
215
- NODE
4
+ exec node "$(dirname "$0")/legacy-entry.mjs" doctor "$@"
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // 既存のsetup記録があるprojectだけを巡回する。npm導入からは呼ばない。
3
+ import { existsSync, readdirSync, statSync, writeFileSync, appendFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import { AitermClient } from './aiterm-client.mjs'
7
+ import { RoomApi } from './room-api.mjs'
8
+ import { ensureProjectRuntime } from './ensure-project-runtime.mjs'
9
+ import { resumeProject } from './project-runtime.mjs'
10
+ import { readSetup } from './project-scaffold.mjs'
11
+ import { findSeatSession } from './seat-session.mjs'
12
+
13
+ const root = resolve(process.argv[2] || join(homedir(), 'Developer'))
14
+ const aiterm = new AitermClient()
15
+ let failed = false
16
+ try {
17
+ const sessions = await aiterm.sessions(['PEERTABLE_MEMBER', 'PEERTABLE_ROOM'])
18
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
19
+ if (!entry.isDirectory()) continue
20
+ const project = join(root, entry.name)
21
+ if (!existsSync(join(project, '.team', 'setup-state.json'))) continue
22
+ try {
23
+ const state = readSetup(project)
24
+ await ensureProjectRuntime(project, { aiterm })
25
+ const members = await new RoomApi(state).members()
26
+ const missing = members.filter(member => member.delivery?.kind !== 'parent_watch' && (member.harness ?? member.vendor)
27
+ && !findSeatSession(member, sessions, state.room))
28
+ if (!missing.length) continue
29
+ const lock = join(project, '.team', 'seat-revive.lock')
30
+ if (existsSync(lock) && Date.now() - statSync(lock).mtimeMs < 600_000) continue
31
+ writeFileSync(lock, `${new Date().toISOString()}\n`)
32
+ console.error(`席消失を検知: ${project}: ${missing.map(member => member.name).join(', ')}。resumeで再開します`)
33
+ const result = await resumeProject({ project })
34
+ appendFileSync(join(project, '.team', 'seat-revive.log'), `${JSON.stringify(result)}\n`)
35
+ } catch (error) {
36
+ failed = true
37
+ console.error(`PEERTABLE_PATROL_FAILED: ${project}: ${error.message}`)
38
+ appendFileSync(join(project, '.team', 'seat-revive.log'), `${new Date().toISOString()} ${error.message}\n`)
39
+ }
40
+ }
41
+ } finally { await aiterm.close() }
42
+ if (failed) process.exitCode = 1
@@ -1,81 +1,4 @@
1
1
  #!/bin/bash
2
- # launchd(dev.kitepon.peertable-bridges)から2分周期で呼ばれ、稼働中の全円卓のbridgeを
3
- # 生存保証する。ensure-bridge.sh は生きていれば何もしない冪等な入口なので、この巡回は
4
- # 「死んだら次の周期で自動再起動」を意味する(オーナー裁定 2026-08-29「毎回手で復活させるな」)。
5
- # 対象卓の発見は実物(.team/setup-state.json の存在)だけを根拠にする。登録簿は持たない。
6
- set -u
7
- # launchd環境にはTMPDIRが無く、tmux socketの既定解決($TMPDIR/claude-tmux-sockets/…)が
8
- # /tmp側へ落ちて全ensureが失敗する(実被弾 2026-08-29)。macOSの正規per-user tempを自前導出する。
9
- if [ -z "${TMPDIR:-}" ] && command -v getconf >/dev/null; then
10
- TMPDIR="$(getconf DARWIN_USER_TEMP_DIR 2>/dev/null || true)"
11
- [ -n "$TMPDIR" ] && export TMPDIR
12
- fi
13
- # launchd環境には書込tokenが無い。bridgeのreceipt書込とresumeの席起動に必要なので、
14
- # 標準の置き場から読み込む(無ければ従来どおり進み、書込systemは自分で degrade を報告する)
15
- [ -f "$HOME/.config/peertable.env" ] && . "$HOME/.config/peertable.env"
16
- scripts="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
17
- for state in /Users/kite/Developer/*/.team/setup-state.json; do
18
- [ -f "$state" ] || continue
19
- proj="$(dirname "$(dirname "$state")")"
20
- bash "$scripts/ensure-bridge.sh" "$proj" seat-status >/dev/null 2>&1 \
21
- || echo "$(date -u +%FT%TZ) ensure失敗: seat-status $proj"
22
- bash "$scripts/ensure-bridge.sh" "$proj" alarm >/dev/null 2>&1 \
23
- || echo "$(date -u +%FT%TZ) ensure失敗: alarm $proj"
24
- # wakeup は配達先の席名一覧が要る。room 台帳から現行メンバーを実物で読む
25
- server="$(python3 -c "import json;print(json.load(open('$state'))['server_url'])" 2>/dev/null)"
26
- room="$(python3 -c "import json;print(json.load(open('$state'))['room'])" 2>/dev/null)"
27
- if [ -n "$server" ] && [ -n "$room" ]; then
28
- seats="$(curl -s --max-time 10 "$server/api/$room/members" | python3 -c "
29
- import json,sys
30
- try:
31
- ms=json.load(sys.stdin).get('members',[])
32
- except Exception:
33
- ms=[]
34
- print(' '.join(m['name'] for m in ms if m.get('observe') or m.get('harness')))" 2>/dev/null)"
35
- if [ -n "$seats" ]; then
36
- # shellcheck disable=SC2086
37
- bash "$scripts/ensure-bridge.sh" "$proj" wakeup $seats >/dev/null 2>&1 \
38
- || echo "$(date -u +%FT%TZ) ensure失敗: wakeup $proj ($seats)"
39
- fi
40
- fi
41
- done
42
-
43
- # ---- 席の自動蘇生(オーナー恒久裁定「死んだら自動で起こせ。毎回手で復活させるな」)----
44
- # tmux server死で全席が消える事故が反復した(実被弾 2026-08-29〜30に3回・原因未特定)。
45
- # 台帳に居るのにtmux sessionが無い席を検知し、**工程が残っている時だけ**resumeで再着席させる。
46
- # 仕事ゼロの時は起こさない(quotaを浪費しない。SEAT_TUI_GONE通知が親へ届く経路は別に生きている)。
47
- # 二重起動はresume側のlaunch-seatが同名席を処理するが、周期の重複実行はlockで抑止する。
48
- for state in /Users/kite/Developer/*/.team/setup-state.json; do
49
- [ -f "$state" ] || continue
50
- proj="$(dirname "$(dirname "$state")")"
51
- lock="$proj/.team/seat-revive.lock"
52
- # 10分cooldown(前回の蘇生が走っていれば触らない)
53
- if [ -f "$lock" ] && [ -n "$(find "$lock" -mmin -10 2>/dev/null)" ]; then continue; fi
54
- server="$(python3 -c "import json;print(json.load(open('$state'))['server_url'])" 2>/dev/null)"
55
- room="$(python3 -c "import json;print(json.load(open('$state'))['room'])" 2>/dev/null)"
56
- [ -n "$server" ] && [ -n "$room" ] || continue
57
- sockprefix=$(node "$scripts/tmux-socket.mjs" --prefix 2>/dev/null) || continue
58
- # shellcheck disable=SC2206
59
- conn=($sockprefix)
60
- dead=""
61
- for seat in $(curl -s --max-time 10 "$server/api/$room/members" | python3 -c "
62
- import json,sys
63
- try: ms=json.load(sys.stdin).get('members',[])
64
- except Exception: ms=[]
65
- print(' '.join(m['name'] for m in ms if m.get('harness')))" 2>/dev/null); do
66
- tmux "${conn[@]}" has-session -t "peer-$seat" 2>/dev/null || dead="$dead $seat"
67
- done
68
- [ -n "$dead" ] || continue
69
- work=$(cd "$proj" && lattice todo status --json 2>/dev/null | python3 -c "
70
- import json,sys
71
- try: d=json.load(sys.stdin)
72
- except Exception: print(0); raise SystemExit
73
- print(len(d.get('next_ready',[]))+len(d.get('active_set',[]))+len(d.get('audit_pending',[])))" 2>/dev/null)
74
- # 工程ゼロでも起こす(オーナー裁定 2026-08-30「席全滅で待機でもないのは何もログ取れない」)。
75
- # 空の卓は障害の再現条件と席側の証人(pane log・rollout)を失う。蘇生コストは席あたり
76
- # 待機宣言1ターンで、観測体制の維持が勝る。
77
- date -u +%FT%TZ > "$lock"
78
- echo "$(date -u +%FT%TZ) 席消失を検知($proj:$dead / 残工程 ${work:-0})。resumeで自動蘇生する"
79
- (cd "$proj" && bash "$scripts/resume.sh" "$proj" >> "$proj/.team/seat-revive.log" 2>&1) \
80
- || echo "$(date -u +%FT%TZ) 自動蘇生に失敗: $proj(seat-revive.log参照)"
81
- done
2
+ # 既存projectの任意巡回。npm導入からは呼ばない。
3
+ set -euo pipefail
4
+ exec node "$(dirname "$0")/ensure-all-bridges.mjs" "$@"
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { ensureBridge } from './ensure-project-runtime.mjs'
3
+ const [project, kind, ...args] = process.argv.slice(2)
4
+ try {
5
+ console.log(JSON.stringify(await ensureBridge(project, kind, {
6
+ force: args.includes('--force'), args: args.filter(arg => arg !== '--force'),
7
+ })))
8
+ } catch (error) {
9
+ console.error(`${error.code ?? 'PEERTABLE_RUNTIME_FAILED'}: ${error.message}`)
10
+ process.exitCode = 1
11
+ }
@@ -1,97 +1,4 @@
1
1
  #!/bin/bash
2
- # bridge を tmux に常駐させ、最初の ready_at まで待つ薄い supervisor。
2
+ # 全OS共通の公開API実装へ渡す旧入口。
3
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" ;; alarm) script="alarm-bridge.mjs" ;; run) echo "ENSURE_BRIDGE_RETIRED: run-bridge は退役した(2026-08-22)。介入は席が自分の Lattice コマンド応答で受け取る" >&2; exit 1 ;; *) echo "usage: ensure-bridge.sh <project> <seat-status|wakeup|alarm> [args...]" >&2; exit 1 ;; esac
7
- scripts_dir=$(cd "$(dirname "$0")" && pwd -P)
8
- repo=$(cd "$scripts_dir/../.." && pwd -P)
9
- peertable_version=$(node -p "require(process.argv[1]).version" "$repo/package.json")
10
- peertable_runtime_digest=$(node "$scripts_dir/runtime-digest.mjs")
11
- team="$proj/.team"; record="$team/$name-bridge.json"; log="$team/$name-bridge.log"
12
- force=false
13
- if [ "${1:-}" = "--force" ]; then force=true; shift; fi
14
- if [ $# -eq 0 ] && [ -f "$record" ]; then
15
- saved=()
16
- 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")
17
- # macOS 標準 bash 3.2 の set -u では、空配列の展開が unbound variable になる。
18
- # args が空なら現在の「引数なし」を保ち、値がある時だけ復元する。
19
- if [ "${#saved[@]}" -gt 0 ]; then
20
- set -- "${saved[@]}"
21
- fi
22
- fi
23
- if [ -f "$record" ]; then
24
- if node "$scripts_dir/bridge-record-live.mjs" "$record"; then
25
- record_version=$(node -e 'const x=require(process.argv[1]);process.stdout.write(x.peertable_version||"")' "$record")
26
- record_digest=$(node -e 'const x=require(process.argv[1]);process.stdout.write(x.peertable_runtime_digest||"")' "$record")
27
- if [ "$record_version" = "$peertable_version" ] && [ "$record_digest" = "$peertable_runtime_digest" ]; then exit 0; fi
28
- echo "${name}-bridge: Peertable ${record_version:-unknown} → ${peertable_version} の現行runtimeへ自動更新する" >&2
29
- node "$scripts_dir/$script" "$proj" --stop || {
30
- echo "${name}-bridge: 旧版bridgeを停止できないため更新を中止する" >&2
31
- exit 1
32
- }
33
- fi
34
- if ! "$force" && grep -q 'WRITE_DENIED' "$log" 2>/dev/null; then echo "${name}-bridge: 前回はWRITE_DENIEDで終了。--forceを指定すること" >&2; exit 1; fi
35
- fi
36
- # shellcheck disable=SC1091
37
- . "$(dirname "$0")/tmux-at.bash"
38
- sock=$(node "$(dirname "$0")/tmux-socket.mjs")
39
- room=$(node -e 'process.stdout.write(require(process.argv[1]).room)' "$team/setup-state.json")
40
- session="peertable-${name}-${room}"
41
- # **session が在るだけでは常駐が生きている証拠にならない**(中の node だけ死んで殻が残る)。
42
- # pid/進捗の生存確認を通れずここへ来たsessionは、Windowsでlogを開いたままの可能性がある。
43
- # log切詰めより先に畳み、EBUSYを発生させない。
44
- tmux_at kill-session -t "$session" 2>/dev/null || true
45
- # **死んだ記録をここで消す。** 残すと下の loop が前回の `ready_at` を読んで、
46
- # 新しい bridge が1バイトも動いていない段階で success を返す——「起動していないのに
47
- # 起動したと言う」=この supervisor が塞ぐはずの穴そのものになる。
48
- rm -f "$record"
49
- if ! node -e 'import { writeFileSync } from "node:fs"; writeFileSync(process.argv[1], "")' "$log"; then
50
- echo "${name}-bridge: log を切り詰められないので追記する" >&2
51
- : >> "$log" || true
52
- fi
53
- # **手渡された env を常駐へ渡す。** 新しい session が継ぐのは tmux *server* の環境であって
54
- # 呼び出し元 client の環境ではないので、素で起こすと `PEERTABLE_TMUX_SOCKET` の手渡しが黙って消え、
55
- # 常駐が別の socket(本番の既定)を観測しにいく(2026-08-11 実測)。決定73 と同じ形の裏返しである。
56
- # **秘密値はコマンドラインへ載せない。** `env PEERTABLE_POST_TOKEN=… node …` の形は
57
- # tmux のsession起動コマンドと ps の両方に平文で残る(2026-08-30 実測)。トークンは
58
- # 0600 の credential file へ落とし、常駐へはそのパスだけを渡す(bridge 側の
59
- # resolvePostToken が PEERTABLE_CREDENTIAL_FILE → ~/.config/peertable.env の順で読む)
60
- if [ -n "${PEERTABLE_POST_TOKEN:-}" ] && [ -z "${PEERTABLE_CREDENTIAL_FILE:-}" ]; then
61
- cred_dir="$proj/.team/credentials"
62
- mkdir -p "$cred_dir" && chmod 700 "$cred_dir"
63
- ( umask 177; printf '%s\n' "$PEERTABLE_POST_TOKEN" > "$cred_dir/bridge.token" )
64
- PEERTABLE_CREDENTIAL_FILE="$cred_dir/bridge.token"
65
- export PEERTABLE_CREDENTIAL_FILE
66
- fi
67
- env_prefix=""
68
- for v in PEERTABLE_TMUX_SOCKET PEERTABLE_CREDENTIAL_FILE PEERTABLE_URL PEERTABLE_PARENT_NAME; do
69
- eval "val=\${$v:-}"
70
- [ -n "$val" ] && env_prefix="$env_prefix $v=$(printf '%q' "$val")"
71
- done
72
- if [ "$(node -p 'process.platform')" = "win32" ]; then
73
- # psmuxが起動する正規shellはPowerShell 7。POSIXの`env`とMSYS pathを文字列のまま
74
- # 渡すとnodeへ到達する前に終了するため、Windows nodeに各pathをargvとして変換させ、
75
- # PowerShell EncodedCommandを一箇所で組み立てる。
76
- win_command=$(node "$scripts_dir/platform/windows/build-bridge-command.mjs" \
77
- "$scripts_dir/$script" "$proj" "$log" "$@")
78
- tmux_at new-session -d -s "$session" "$win_command"
79
- else
80
- tmux_at new-session -d -s "$session" "env${env_prefix} node $(dirname "$0")/$script $(printf '%q ' "$proj" "$@") >> $(printf '%q' "$log") 2>&1"
81
- fi
82
- for _ in $(seq 1 30); do
83
- if [ -f "$record" ] && node -e 'process.exit(require(process.argv[1]).ready_at?0:1)' "$record"; then
84
- # **末尾は本物の改行にする。** `"\\n"` と書くと JS がリテラルの `\`+`n` を足し、
85
- # record が JSON として壊れる——`--stop` も次回起動も `JSON.parse` で落ちて、
86
- # 「止められない・建て直せない常駐」ができる(2026-08-11 実測)。一時 file→rename で原子的に。
87
- node -e 'const fs=require("fs");const p=process.argv[1],version=process.argv[2],digest=process.argv[3],a=process.argv.slice(4);
88
- const t=`${p}.${process.pid}.tmp`;
89
- fs.writeFileSync(t,JSON.stringify({...JSON.parse(fs.readFileSync(p,"utf8")),peertable_version:version,peertable_runtime_digest:digest,args:a})+"\n");
90
- fs.renameSync(t,p)' "$record" "$peertable_version" "$peertable_runtime_digest" "$@"
91
- exit 0
92
- fi
93
- sleep .5
94
- done
95
- echo "${name}-bridge: ready_at を待てなかった。ログ末尾:" >&2
96
- tail -n 20 "$log" >&2 || true
97
- exit 1
4
+ exec node "$(dirname "$0")/ensure-bridge.mjs" "$@"
@@ -77,7 +77,7 @@ function expectedBlock(addedNewline) {
77
77
  '[mcp_servers.room]',
78
78
  'command = "node"',
79
79
  `args = [${JSON.stringify(client)}]`,
80
- 'env_vars = ["TMUX", "TMUX_PANE"]',
80
+ 'env_vars = ["AITERM_SESSION_ID"]',
81
81
  '[mcp_servers.room.env]',
82
82
  ...explicitEnv,
83
83
  endMarker,
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // 3bridgeを順に起動・更新し、実際のready記録まで確認する。
3
+ import { createHash } from 'node:crypto'
4
+ import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
5
+ import { join, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { setTimeout as delay } from 'node:timers/promises'
8
+ import { AitermClient } from './aiterm-client.mjs'
9
+ import { bridgeRecordLive } from './bridge-record-live.mjs'
10
+ import { packageRoot } from './install-skill.mjs'
11
+ import { projectPath, readSetup, readJson, runScript, fail } from './project-scaffold.mjs'
12
+ import { runtimeLaunchCommand } from './runtime-launch-command.mjs'
13
+
14
+ export const bridgeKinds = Object.freeze(['alarm', 'seat-status', 'wakeup'])
15
+ export const bridgeSession = (project, room, kind) => `peertable-${kind}-${room}-${createHash('sha256').update(project).digest('hex').slice(0, 8)}`
16
+
17
+ export async function ensureBridge(projectArg, kind, { root = packageRoot, env = process.env, aiterm, args = [], force = false } = {}) {
18
+ if (!bridgeKinds.includes(kind)) fail('ENSURE_BRIDGE_KIND_INVALID', kind)
19
+ const project = projectPath(projectArg)
20
+ const state = readSetup(project)
21
+ const team = join(project, '.team')
22
+ const recordPath = join(team, `${kind}-bridge.json`), log = join(team, `${kind}-bridge.log`)
23
+ const script = `${kind}-bridge.mjs`
24
+ const version = readJson(join(root, 'package.json')).version
25
+ const digest = runScript('runtime-digest.mjs', [], { root })
26
+ const previous = existsSync(recordPath) ? readJson(recordPath) : null
27
+ if (bridgeRecordLive(previous) && previous.peertable_version === version && previous.peertable_runtime_digest === digest)
28
+ return { kind, status: 'current', session_id: previous.aiterm_session_id ?? null }
29
+ const ownClient = !aiterm
30
+ aiterm ??= new AitermClient({ env })
31
+ try {
32
+ if (previous && bridgeRecordLive(previous)) runScript(script, [project, '--stop'], { root, env })
33
+ const session = previous?.aiterm_session_id ?? (previous ? `peertable-${kind}-${state.room}` : bridgeSession(project, state.room, kind))
34
+ await aiterm.structured('pty_close', { session_id: session }, 'aiterm.pty-close-result.v1')
35
+ if (existsSync(recordPath)) unlinkSync(recordPath)
36
+ writeFileSync(log, '')
37
+ const launchEnv = { ...env }
38
+ if (launchEnv.PEERTABLE_POST_TOKEN && !launchEnv.PEERTABLE_CREDENTIAL_FILE) {
39
+ const credentials = join(team, 'credentials')
40
+ mkdirSync(credentials, { recursive: true, mode: 0o700 })
41
+ launchEnv.PEERTABLE_CREDENTIAL_FILE = join(credentials, 'bridge.token')
42
+ writeFileSync(launchEnv.PEERTABLE_CREDENTIAL_FILE, `${launchEnv.PEERTABLE_POST_TOKEN}\n`, { mode: 0o600 })
43
+ }
44
+ if (!launchEnv.PEERTABLE_CREDENTIAL_FILE) {
45
+ launchEnv.PEERTABLE_CREDENTIAL_FILE = runScript('seat-credential.mjs', ['prepare', project, state.room, 'runtime'], { env: launchEnv })
46
+ }
47
+ const effectiveArgs = args.length ? args : previous?.args ?? []
48
+ await aiterm.call('pty_open', { name: session })
49
+ await aiterm.structured('pty_send', { session_id: session, text: runtimeLaunchCommand({
50
+ executable: process.execPath, script: join(root, 'skill', 'scripts', script), project,
51
+ args: effectiveArgs, log, env: launchEnv,
52
+ }) }, 'aiterm.pty-send-result.v1')
53
+ const deadline = Date.now() + 15_000
54
+ while (Date.now() < deadline) {
55
+ const record = existsSync(recordPath) ? readJson(recordPath) : null
56
+ if (record?.ready_at && bridgeRecordLive(record)) {
57
+ const temporary = `${recordPath}.${process.pid}.tmp`
58
+ writeFileSync(temporary, `${JSON.stringify({ ...record, peertable_version: version, peertable_runtime_digest: digest,
59
+ args: effectiveArgs, aiterm_session_id: session })}\n`)
60
+ renameSync(temporary, recordPath)
61
+ return { kind, status: previous ? 'updated' : 'started', session_id: session }
62
+ }
63
+ await delay(200)
64
+ }
65
+ fail('PEERTABLE_RUNTIME_NOT_READY', `${kind}のready記録を確認できません。${log}`)
66
+ } finally {
67
+ if (ownClient) await aiterm.close()
68
+ }
69
+ }
70
+
71
+ export async function ensureProjectRuntime(project, options = {}) {
72
+ const aiterm = options.aiterm ?? new AitermClient({ env: options.env })
73
+ try {
74
+ const bridges = []
75
+ for (const kind of bridgeKinds) bridges.push(await ensureBridge(project, kind, { ...options, aiterm }))
76
+ return { schema: 'peertable.runtime-result.v1', status: 'ready', bridges }
77
+ } finally {
78
+ if (!options.aiterm) await aiterm.close()
79
+ }
80
+ }
81
+
82
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
83
+ try { console.log(JSON.stringify(await ensureProjectRuntime(process.argv[2], { force: process.argv.includes('--force') }))) }
84
+ catch (error) { console.error(`${error.code ?? 'PEERTABLE_RUNTIME_FAILED'}: ${error.message}`); process.exitCode = 1 }
85
+ }
@@ -1,13 +1,4 @@
1
1
  #!/bin/bash
2
- # 円卓runtimeを一回の呼出しで現行Peertable版へ収束させる正規入口。
2
+ # 全OS共通の公開API実装へ渡す旧入口。
3
3
  set -euo pipefail
4
-
5
- proj="${1:-}"
6
- [ -n "$proj" ] || { echo "usage: ensure-project-runtime.sh <project_dir>" >&2; exit 2; }
7
- scripts=$(cd "$(dirname "$0")" && pwd -P)
8
-
9
- for kind in alarm seat-status wakeup; do
10
- "$scripts/ensure-bridge.sh" "$proj" "$kind"
11
- done
12
-
13
- echo "peertable runtime ready: alarm / seat-status / wakeup"
4
+ exec node "$(dirname "$0")/ensure-project-runtime.mjs" "$@"
@@ -0,0 +1,40 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { ensureBridge } from './ensure-project-runtime.mjs'
7
+
8
+ test('新規runtimeは指定した資格元からファイルを準備して公開PTYへ渡す', async t => {
9
+ const project = realpathSync(mkdtempSync(join(tmpdir(), 'peertable-runtime-credential-')))
10
+ t.after(() => rmSync(project, { recursive: true, force: true }))
11
+ const team = join(project, '.team')
12
+ mkdirSync(team)
13
+ writeFileSync(join(team, 'setup-state.json'), JSON.stringify({ room: 'fixture', server_url: 'http://localhost', mode: 'standalone' }))
14
+ const source = join(project, 'source.env')
15
+ writeFileSync(source, 'PEERTABLE_POST_TOKEN=fixture-credential-123\n', { mode: 0o600 })
16
+ const env = { ...process.env, PEERTABLE_TOKEN_SOURCE_FILE: source }
17
+ delete env.PEERTABLE_POST_TOKEN
18
+ delete env.PEERTABLE_CREDENTIAL_FILE
19
+ let sent = false
20
+ const aiterm = {
21
+ call: async tool => { assert.equal(tool, 'pty_open') },
22
+ structured: async (tool, args) => {
23
+ if (tool === 'pty_close') return {}
24
+ assert.equal(tool, 'pty_send')
25
+ const credentials = join(team, 'credentials')
26
+ const files = readdirSync(credentials)
27
+ assert.equal(files.length, 1)
28
+ assert.equal(readFileSync(join(credentials, files[0]), 'utf8'), 'fixture-credential-123\n')
29
+ assert.ok(args.text.includes(files[0]))
30
+ assert.ok(args.text.includes('PEERTABLE_CREDENTIAL_FILE'))
31
+ assert.ok(!args.text.includes('fixture-credential-123'))
32
+ const stamp = new Date().toISOString()
33
+ writeFileSync(join(team, 'alarm-bridge.json'), JSON.stringify({ pid: process.pid, ready_at: stamp, last_progress_at: stamp }))
34
+ sent = true
35
+ return {}
36
+ },
37
+ }
38
+ assert.equal((await ensureBridge(project, 'alarm', { aiterm, env })).status, 'started')
39
+ assert.equal(sent, true)
40
+ })