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.
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +2 -2
- package/room/Dockerfile +2 -1
- package/room/client.mjs +5 -12
- package/room/server.mjs +121 -39
- package/skill/SKILL.md +13 -8
- package/skill/scripts/alarm-bridge.mjs +117 -0
- package/skill/scripts/alarm-set.sh +22 -0
- package/skill/scripts/change-seat.sh +2 -1
- package/skill/scripts/codex-dialog.mjs +46 -0
- package/skill/scripts/codex-seat-toml.mjs +6 -0
- package/skill/scripts/doctor.sh +185 -0
- package/skill/scripts/ensure-bridge.sh +1 -1
- package/skill/scripts/ensure-codex-room-mcp.mjs +4 -1
- package/skill/scripts/launch-seat.sh +141 -120
- package/skill/scripts/leave-seat.sh +1 -4
- package/skill/scripts/parent-join.sh +22 -9
- package/skill/scripts/parent-watch-logic.mjs +33 -0
- package/skill/scripts/parent-watch.mjs +34 -1
- package/skill/scripts/pull-attach-input.mjs +53 -0
- package/skill/scripts/refresh-seat-identity.mjs +97 -0
- package/skill/scripts/seat-identity.mjs +37 -6
- package/skill/scripts/seat-status-bridge.mjs +34 -0
- package/skill/scripts/seat-usage.mjs +5 -0
- package/skill/scripts/setup.sh +5 -0
- package/skill/scripts/teardown.sh +8 -6
- package/skill/scripts/wakeup-bridge.mjs +91 -2
- package/skill/scripts/wakeup-delivery.mjs +1 -1
- package/skill/templates/member.md +10 -5
- package/skill/scripts/run-bridge.mjs +0 -272
- package/skill/scripts/seat-metadata.mjs +0 -21
|
@@ -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
|
-
|
|
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" ;;
|
|
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) {
|