peertable 0.4.37 → 0.5.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.
@@ -59,7 +59,8 @@ name=sys.argv[1]
59
59
  member=next((m for m in json.load(sys.stdin).get("members",[]) if m.get("name")==name),None)
60
60
  if not member or member.get("vendor") not in ("claude","codex","grok") or not member.get("model"):
61
61
  raise SystemExit(1)
62
- print("\t".join((member["vendor"],member["model"],member.get("effort") or "",member.get("aiterm_session_id") or "",member.get("role") or "")))
62
+ roles=member.get("roles") or []
63
+ print("\t".join((member["vendor"],member["model"],member.get("effort") or "",member.get("aiterm_session_id") or "",",".join(roles))))
63
64
  ' "$name") || { echo "SEAT_CHANGE_MEMBER_METADATA_MISSING: ${name} のvendor/modelが要る" >&2; exit 1; }
64
65
  IFS=$'\t' read -r old_vendor old_model old_effort aiterm_session_id old_role <<EOF
65
66
  $meta
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // Codex TUI の既知ダイアログ → 送るキー。画面文言だけで判定する。未知の確認は通さない。
3
+ import { fileURLToPath } from 'node:url'
4
+ import { resolve } from 'node:path'
5
+
6
+ export const MCP_ALLOW_NEEDLE = 'Allow the room MCP server to run tool'
7
+ export const MCP_ALWAYS_ALLOW = 'Always allow'
8
+ export const COMMAND_APPROVAL_NEEDLE = 'Would you like to run the following command?'
9
+ export const COMMAND_APPROVAL_DONT_ASK = "don't ask again"
10
+
11
+ export function keysForCodexPane(screen) {
12
+ const text = String(screen ?? '')
13
+ if (text.includes(MCP_ALLOW_NEEDLE) && text.includes(MCP_ALWAYS_ALLOW)) {
14
+ return { kind: 'mcp-allow', keys: ['Down', 'Down', 'Enter'] }
15
+ }
16
+ if (text.includes(COMMAND_APPROVAL_NEEDLE) && text.toLowerCase().includes(COMMAND_APPROVAL_DONT_ASK)) {
17
+ return { kind: 'command-approval', keys: ['Down', 'Enter'] }
18
+ }
19
+ if (text.includes('Hooks need review')) {
20
+ return { kind: 'hooks', keys: ['Down', 'Enter'] }
21
+ }
22
+ if (text.includes('Update now')) {
23
+ return { kind: 'update', keys: ['Down', 'Enter'] }
24
+ }
25
+ if (text.includes('Yes, continue') && text.includes('Do you trust the contents of this directory')) {
26
+ return { kind: 'trust', keys: ['Enter'] }
27
+ }
28
+ return null
29
+ }
30
+
31
+ export function blocksCodexReady(screen) {
32
+ const kind = keysForCodexPane(screen)?.kind
33
+ return kind === 'mcp-allow' || kind === 'command-approval'
34
+ }
35
+
36
+ const isMain = Boolean(process.argv[1]) && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
37
+ if (isMain) {
38
+ const chunks = []
39
+ for await (const chunk of process.stdin) chunks.push(chunk)
40
+ const screen = Buffer.concat(chunks).toString('utf8')
41
+ const action = keysForCodexPane(screen)
42
+ if (process.argv.includes('--ready-ok')) {
43
+ process.exit(blocksCodexReady(screen) ? 2 : 0)
44
+ }
45
+ process.stdout.write(`${JSON.stringify(action)}\n`)
46
+ }
@@ -0,0 +1,6 @@
1
+ export function repairSplicedBegin(text) {
2
+ return String(text ?? '').replace(
3
+ /^# BEGIN PEERTABLE ROOM MCP\napproval_policy = "never"\nsandbox_mode = "danger-full-access" added_newline=([01])\n/mu,
4
+ '# BEGIN PEERTABLE ROOM MCP added_newline=$1\napproval_policy = "never"\nsandbox_mode = "danger-full-access"\n',
5
+ )
6
+ }
@@ -0,0 +1,185 @@
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)。
18
+ 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 { 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
+ // 5. Lattice 併用モードの工程正本
170
+ if (mode === 'lattice') {
171
+ const latticeCli = process.env.LATTICE_CLI || (typeof setup.lattice_cli === 'string' && setup.lattice_cli) || 'lattice'
172
+ try {
173
+ const command = process.platform === 'win32' && !/\.(cmd|bat|exe)$/i.test(latticeCli) && existsSync(`${latticeCli}.cmd`)
174
+ ? `${latticeCli}.cmd`
175
+ : latticeCli
176
+ const out = execFileSync(command, ['status', '--json'], { cwd: proj, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 })
177
+ const status = JSON.parse(out)
178
+ line('OK', `Lattice: state=${status.state} active_runs=${(status.active_runs ?? []).length}`)
179
+ } catch (error) {
180
+ line('NG', `Lattice status を取得できない(${String(error.message ?? error).split('\n')[0]})`)
181
+ }
182
+ }
183
+
184
+ process.exit(ng)
185
+ NODE
@@ -3,7 +3,7 @@
3
3
  set -euo pipefail
4
4
 
5
5
  proj="$1"; name="$2"; shift 2
6
- case "$name" in seat-status) script="seat-status-bridge.mjs" ;; wakeup) script="wakeup-bridge.mjs" ;; run) script="run-bridge.mjs" ;; *) echo "usage: ensure-bridge.sh <project> <seat-status|wakeup|run> [args...]" >&2; exit 1 ;; esac
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
7
  team="$proj/.team"; record="$team/$name-bridge.json"; log="$team/$name-bridge.log"
8
8
  force=false
9
9
  if [ "${1:-}" = "--force" ]; then force=true; shift; fi
@@ -6,6 +6,7 @@ import {
6
6
  } from 'node:fs'
7
7
  import { dirname, join, resolve } from 'node:path'
8
8
  import { execFileSync } from 'node:child_process'
9
+ import { repairSplicedBegin } from './codex-seat-toml.mjs'
9
10
 
10
11
  const fail = (code, message) => {
11
12
  process.stderr.write(`${code}: ${message}\n`)
@@ -71,6 +72,8 @@ function expectedBlock(addedNewline) {
71
72
  if (process.env.PATH) explicitEnv.unshift(`PATH = ${JSON.stringify(process.env.PATH)}`)
72
73
  return [
73
74
  `# BEGIN PEERTABLE ROOM MCP added_newline=${addedNewline ? '1' : '0'}`,
75
+ 'approval_policy = "never"',
76
+ 'sandbox_mode = "danger-full-access"',
74
77
  '[mcp_servers.room]',
75
78
  'command = "node"',
76
79
  `args = [${JSON.stringify(client)}]`,
@@ -120,7 +123,7 @@ try {
120
123
  const missing = requiredSeatEnv.filter((name) => !process.env[name])
121
124
  if (missing.length > 0)
122
125
  fail('SEAT_CODEX_ROOM_MCP_ENV_MISSING', `seat環境が無い: ${missing.join(',')}`)
123
- const current = existsSync(configFile) ? readFileSync(configFile, 'utf8') : ''
126
+ const current = repairSplicedBegin(existsSync(configFile) ? readFileSync(configFile, 'utf8') : '')
124
127
  const range = markerRange(current)
125
128
  let next
126
129
  if (range) {