peertable 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +8 -4
- package/README.md +8 -4
- package/package.json +1 -1
- package/room/client.mjs +36 -56
- package/room/server.mjs +77 -16
- package/skill/SKILL.md +73 -34
- package/skill/scripts/aiterm-configure.mjs +31 -0
- package/skill/scripts/aiterm-launch.mjs +29 -0
- package/skill/scripts/archive-room-log.py +3 -1
- package/skill/scripts/change-effort.sh +5 -129
- package/skill/scripts/change-seat.sh +253 -0
- package/skill/scripts/codex-parent-watch.sh +8 -0
- package/skill/scripts/ensure-bridge.sh +6 -2
- package/skill/scripts/ensure-codex-room-mcp.mjs +152 -0
- package/skill/scripts/ensure-room-mcp.mjs +52 -0
- package/skill/scripts/launch-seat.sh +392 -94
- package/skill/scripts/leave-seat.sh +90 -0
- package/skill/scripts/parent-join.sh +46 -6
- package/skill/scripts/parent-watch.mjs +286 -0
- package/skill/scripts/seat-credential.mjs +118 -0
- package/skill/scripts/seat-status-bridge.mjs +10 -3
- package/skill/scripts/seat-usage.mjs +44 -3
- package/skill/scripts/setup.sh +30 -3
- package/skill/scripts/teardown.sh +12 -3
- package/skill/scripts/todo-extraction-from-plan.mjs +199 -0
- package/skill/scripts/upgrade-team-assets.sh +224 -0
- package/skill/scripts/wakeup-bridge.mjs +245 -58
- package/skill/templates/charter.md +12 -6
- package/skill/templates/done.sh +379 -36
- package/skill/templates/mcp.json +1 -1
- package/skill/templates/member-standalone.md +24 -14
- package/skill/templates/member.md +31 -33
- package/skill/templates/parent.md +128 -0
|
@@ -1,31 +1,40 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# 親(ベル等)が room へ着卓する。
|
|
3
|
-
# usage: parent-join.sh <project_dir> [name] [model] [effort]
|
|
3
|
+
# usage: parent-join.sh <project_dir> [name] [model] [effort] [vendor]
|
|
4
4
|
# name 既定は bell。broadcast廃止に伴いkickoff投稿は行わない。
|
|
5
|
-
# model / effort は任意。親はオーナーの対話セッション(決定40)なので、席と違って
|
|
5
|
+
# model / effort / vendor は任意。親はオーナーの対話セッション(決定40)なので、席と違って
|
|
6
6
|
# 起動時に確定した値を script が知らない——**渡された時だけ**参加者一覧の素性として登録する。
|
|
7
7
|
# 渡さなければ欄ごと出ない(「不明」ではなく「素性を名乗っていない」)。
|
|
8
|
+
# vendor は claude(既定)または codex。model だけ渡して vendor を渡さない場合は claude とみなす
|
|
9
|
+
# (後方互換)。
|
|
8
10
|
# 親は MCP を後付けできないので room へは HTTP API 直で入る(決定40 の operating notes)。
|
|
9
11
|
set -e
|
|
10
|
-
proj="$1"; name="${2:-bell}"; model="$3"; effort="$4"
|
|
12
|
+
proj="$1"; name="${2:-bell}"; model="$3"; effort="$4"; vendor="$5"
|
|
11
13
|
state="$proj/.team/setup-state.json"
|
|
12
14
|
room=$(python3 -c "import json;print(json.load(open('$state'))['room'])")
|
|
13
15
|
url=$(python3 -c "import json;print(json.load(open('$state'))['server_url'])")
|
|
16
|
+
mode=$(python3 -c "import json;print(json.load(open('$state')).get('mode',''))")
|
|
14
17
|
|
|
15
18
|
if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; then
|
|
16
19
|
. "$HOME/.config/peertable.env"
|
|
17
20
|
fi
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
# 親はAiterm席ではない。Claude/Codexとも、親自身が所有するparent-watchを配送先にする。
|
|
23
|
+
# tmux observeやCodex thread IDを登録すると、通常席bridge/外部resumeへ誤配送される。
|
|
24
|
+
parent_vendor="${vendor:-claude}"
|
|
25
|
+
member=$(python3 - "$name" "$model" "$effort" "$parent_vendor" <<'PY'
|
|
20
26
|
import json, sys
|
|
21
|
-
name, model, effort = sys.argv[1:
|
|
27
|
+
name, model, effort, vendor = sys.argv[1:5]
|
|
22
28
|
body = {'name': name}
|
|
23
29
|
# 渡された欄だけ載せる。空欄を送ると、素性を持つ既存登録を空で上書きしうる
|
|
30
|
+
if model or vendor:
|
|
31
|
+
body['vendor'] = vendor or 'claude'
|
|
24
32
|
if model:
|
|
25
|
-
body['vendor'] = 'claude'
|
|
26
33
|
body['model'] = model
|
|
27
34
|
if effort:
|
|
28
35
|
body['effort'] = effort
|
|
36
|
+
body['observe'] = None
|
|
37
|
+
body['delivery'] = {'kind': 'parent_watch', 'host': vendor}
|
|
29
38
|
print(json.dumps(body))
|
|
30
39
|
PY
|
|
31
40
|
)
|
|
@@ -34,4 +43,35 @@ curl -sf -X POST "$url/api/$room/members" \
|
|
|
34
43
|
-d "$member" > /dev/null
|
|
35
44
|
echo "joined: ${name}(room=${room})"
|
|
36
45
|
|
|
46
|
+
here="$(cd "$(dirname "$0")" && pwd)"
|
|
47
|
+
|
|
48
|
+
# owner裁定[46]④: 子processのexportは親shellへ伝播しないため、Lattice mutation
|
|
49
|
+
# (todo reopen 等)に要る actor 環境変数は親自身が source する持続ファイルとして配る。
|
|
50
|
+
if [ "$mode" = "lattice" ]; then
|
|
51
|
+
env_file="$proj/.team/parent-env.sh"
|
|
52
|
+
cat > "$env_file" <<EOF
|
|
53
|
+
export LATTICE_TODO_ACTOR_HOST=mac
|
|
54
|
+
export LATTICE_TODO_ACTOR_SESSION=${name}
|
|
55
|
+
export LATTICE_TODO_ACTOR_AGENT=${name}
|
|
56
|
+
EOF
|
|
57
|
+
echo "Lattice mutation(todo reopen 等)を打つ前に: source ${env_file}"
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
if [ -f "$proj/.team/roles/parent.md" ]; then
|
|
61
|
+
echo "親役割は $proj/.team/roles/parent.md を読むこと"
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# parent-watchが初回headを固定する。以後のDMはwatcher不在時間を含め
|
|
65
|
+
# 永続cursorからcatch-upされる。host内のbackground task自体は親セッションだけが所有できる。
|
|
66
|
+
if PEERTABLE_PARENT_HOST="$parent_vendor" node "$here/parent-watch.mjs" "$proj" "$name" --prime; then
|
|
67
|
+
echo "parent-watch cursor ready: ${name}(host=${parent_vendor})"
|
|
68
|
+
if [ "$parent_vendor" = "codex" ]; then
|
|
69
|
+
echo "PARENT_WATCH_START_REQUIRED: Codex親のbackground taskで1秒ごとに ${here}/codex-parent-watch.sh ${proj} ${name} を都度実行し、空でないstdoutだけを親turnへnotifyすること。background taskはloopを続けるが、Node processや端末sessionは常駐させない"
|
|
70
|
+
else
|
|
71
|
+
echo "PARENT_WATCH_START_REQUIRED: Claude Monitor(persistent)で ${here}/parent-watch.mjs ${proj} ${name} --follow を1回だけ起動し、通知後も同じMonitorで待機を続けること"
|
|
72
|
+
fi
|
|
73
|
+
else
|
|
74
|
+
echo "WARN: PARENT_WATCH_PRIME_FAILED: 親(${name})のDM cursorを準備できない" >&2
|
|
75
|
+
fi
|
|
76
|
+
|
|
37
77
|
curl -sf "$url/api/$room/members" | python3 -c "import json,sys;print('members:', ', '.join(m['name'] for m in json.load(sys.stdin)['members']))"
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 親セッション自身が所有するバックグラウンド通知用の room watcher。
|
|
3
|
+
// room/SSE の解釈はここへ集約し、Claude Monitor / Codex background task は
|
|
4
|
+
// この process が stdout へ出す構造化 event を親へ返すことだけを担う。
|
|
5
|
+
//
|
|
6
|
+
// usage: parent-watch.mjs <project_dir> [parent_name] --prime
|
|
7
|
+
// parent-watch.mjs <project_dir> [parent_name] --poll
|
|
8
|
+
// parent-watch.mjs <project_dir> [parent_name] --next
|
|
9
|
+
// parent-watch.mjs <project_dir> [parent_name] --follow
|
|
10
|
+
import { execFileSync } from 'node:child_process'
|
|
11
|
+
import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2)
|
|
15
|
+
const project = args.shift()
|
|
16
|
+
let parent = 'bell'
|
|
17
|
+
if (args[0] && !args[0].startsWith('--')) parent = args.shift()
|
|
18
|
+
const mode = args.shift() ?? '--follow'
|
|
19
|
+
if (!project || !['--prime', '--poll', '--next', '--follow'].includes(mode) || args.length > 0) {
|
|
20
|
+
console.error('usage: parent-watch.mjs <project_dir> [parent_name] <--prime|--poll|--next|--follow>')
|
|
21
|
+
process.exit(2)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const team = join(project, '.team')
|
|
25
|
+
const setupPath = join(team, 'setup-state.json')
|
|
26
|
+
const statePath = join(team, 'parent-watch.json')
|
|
27
|
+
const lockPath = join(team, 'parent-watch.lock')
|
|
28
|
+
const setup = JSON.parse(readFileSync(setupPath, 'utf8'))
|
|
29
|
+
const room = setup.room
|
|
30
|
+
const serverUrl = setup.server_url.replace(/\/$/u, '')
|
|
31
|
+
const latticeCli = setup.lattice_cli || process.env.LATTICE_CLI || 'lattice'
|
|
32
|
+
const api = `${serverUrl}/api/${encodeURIComponent(room)}`
|
|
33
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
34
|
+
const now = () => new Date().toISOString()
|
|
35
|
+
const ROOM_UPDATE_BODY = 'room全体の状況が更新された。roomログを読み、状況を把握して次の行動を判断する。'
|
|
36
|
+
const staffingBody = ({ ready, active }) => `現在、着手可能工程は ${ready} 件、着手中工程は ${active} 件になりました。標準は ${ready + active}+監査担当数です。円卓メンバー数を検討してください。`
|
|
37
|
+
|
|
38
|
+
function readLatticeState(previous = null) {
|
|
39
|
+
const manifest = join(project, '.lattice', 'todo', 'manifest.json')
|
|
40
|
+
if (!existsSync(manifest)) return null
|
|
41
|
+
let info
|
|
42
|
+
try { info = statSync(manifest) } catch { return null }
|
|
43
|
+
const source = `${info.mtimeMs}:${info.size}`
|
|
44
|
+
if (previous?.source === source && previous.error === undefined) return previous
|
|
45
|
+
try {
|
|
46
|
+
const status = JSON.parse(execFileSync(latticeCli, ['todo', 'status', '--json'], {
|
|
47
|
+
cwd: project,
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
}))
|
|
51
|
+
if (!Array.isArray(status.next_ready) || !Array.isArray(status.active_set)) {
|
|
52
|
+
throw new Error('invalid schema')
|
|
53
|
+
}
|
|
54
|
+
return { ready: status.next_ready.length, active: status.active_set.length, source }
|
|
55
|
+
} catch {
|
|
56
|
+
return { error: 'LATTICE_TODO_STATUS_FAILED', source }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function loadState() {
|
|
61
|
+
if (!existsSync(statePath)) return null
|
|
62
|
+
try {
|
|
63
|
+
const saved = JSON.parse(readFileSync(statePath, 'utf8'))
|
|
64
|
+
if (saved.room !== room || saved.server_url !== serverUrl || saved.parent !== parent) return null
|
|
65
|
+
if (!Number.isSafeInteger(saved.last_seq) || saved.last_seq < 0) return null
|
|
66
|
+
return saved
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function saveState(state) {
|
|
73
|
+
const temp = `${statePath}.${process.pid}.tmp`
|
|
74
|
+
writeFileSync(temp, `${JSON.stringify(state)}\n`, { mode: 0o600 })
|
|
75
|
+
renameSync(temp, statePath)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readJson(path) {
|
|
79
|
+
const response = await fetch(`${api}${path}`)
|
|
80
|
+
if (!response.ok) throw new Error(`${path} ${response.status}`)
|
|
81
|
+
return response.json()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function ensurePrimed() {
|
|
85
|
+
const saved = loadState()
|
|
86
|
+
if (saved) {
|
|
87
|
+
let lattice = saved.lattice
|
|
88
|
+
if (!lattice?.source) {
|
|
89
|
+
const observed = readLatticeState()
|
|
90
|
+
lattice = observed?.error ? null : observed
|
|
91
|
+
}
|
|
92
|
+
const next = {
|
|
93
|
+
...saved,
|
|
94
|
+
lattice,
|
|
95
|
+
ready_at: now(),
|
|
96
|
+
host: process.env.PEERTABLE_PARENT_HOST || saved.host || null,
|
|
97
|
+
}
|
|
98
|
+
saveState(next)
|
|
99
|
+
return next
|
|
100
|
+
}
|
|
101
|
+
const summary = await readJson('/summary')
|
|
102
|
+
const observed = readLatticeState()
|
|
103
|
+
const state = {
|
|
104
|
+
schema: 'peertable.parent-watch-state.v1',
|
|
105
|
+
room,
|
|
106
|
+
server_url: serverUrl,
|
|
107
|
+
parent,
|
|
108
|
+
last_seq: Number.isSafeInteger(summary.seq) ? summary.seq : 0,
|
|
109
|
+
lattice: observed?.error ? null : observed,
|
|
110
|
+
ready_at: now(),
|
|
111
|
+
host: process.env.PEERTABLE_PARENT_HOST || null,
|
|
112
|
+
}
|
|
113
|
+
saveState(state)
|
|
114
|
+
return state
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let state = await ensurePrimed()
|
|
118
|
+
if (mode === '--prime') process.exit(0)
|
|
119
|
+
|
|
120
|
+
const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
|
|
121
|
+
function acquireLock() {
|
|
122
|
+
try {
|
|
123
|
+
writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 })
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.code !== 'EEXIST') throw error
|
|
126
|
+
let owner = null
|
|
127
|
+
try { owner = Number(readFileSync(lockPath, 'utf8').trim()) } catch {}
|
|
128
|
+
if (Number.isSafeInteger(owner) && !alive(owner)) {
|
|
129
|
+
unlinkSync(lockPath)
|
|
130
|
+
writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 })
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
console.error(`PARENT_WATCH_ALREADY_RUNNING: pid ${Number.isSafeInteger(owner) ? owner : '不明'}`)
|
|
134
|
+
process.exit(1)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function releaseLock() {
|
|
138
|
+
try {
|
|
139
|
+
if (Number(readFileSync(lockPath, 'utf8').trim()) === process.pid) unlinkSync(lockPath)
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
acquireLock()
|
|
143
|
+
process.on('exit', releaseLock)
|
|
144
|
+
|
|
145
|
+
const addressedToParent = message => message?.from !== parent
|
|
146
|
+
&& (message?.to === 'all' || message?.to === parent
|
|
147
|
+
|| (Array.isArray(message?.to_names) && message.to_names.includes(parent)))
|
|
148
|
+
|
|
149
|
+
async function writeEvent(event) {
|
|
150
|
+
await new Promise((resolve, reject) => {
|
|
151
|
+
process.stdout.write(`${JSON.stringify(event)}\n`, error => error ? reject(error) : resolve())
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function acceptLatticeState(next) {
|
|
156
|
+
if (next === null) return false
|
|
157
|
+
const previous = state.lattice
|
|
158
|
+
if (next.error !== undefined) {
|
|
159
|
+
if (previous?.error === next.error && previous.source === next.source) return false
|
|
160
|
+
state = { ...state, lattice: { ...previous, ...next }, last_event_at: now() }
|
|
161
|
+
saveState(state)
|
|
162
|
+
await writeEvent({
|
|
163
|
+
schema: 'peertable.parent-watch-event.v1',
|
|
164
|
+
type: 'parent_lattice_error',
|
|
165
|
+
parent,
|
|
166
|
+
code: next.error,
|
|
167
|
+
body: 'Lattice工程状態を取得できませんでした。親番犬のroom追従は継続します。',
|
|
168
|
+
})
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
const changed = Number.isSafeInteger(previous?.ready) && Number.isSafeInteger(previous?.active)
|
|
172
|
+
&& previous.ready + previous.active !== next.ready + next.active
|
|
173
|
+
state = { ...state, lattice: next, last_event_at: now() }
|
|
174
|
+
saveState(state)
|
|
175
|
+
if (!changed) return false
|
|
176
|
+
await writeEvent({
|
|
177
|
+
schema: 'peertable.parent-watch-event.v1',
|
|
178
|
+
type: 'parent_lattice_update',
|
|
179
|
+
parent,
|
|
180
|
+
ready: next.ready,
|
|
181
|
+
active: next.active,
|
|
182
|
+
standard_worker_count: next.ready + next.active,
|
|
183
|
+
body: staffingBody(next),
|
|
184
|
+
})
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function acceptMessage(message) {
|
|
189
|
+
if (!Number.isSafeInteger(message?.seq) || message.seq <= state.last_seq) return false
|
|
190
|
+
const matched = addressedToParent(message)
|
|
191
|
+
if (matched) {
|
|
192
|
+
const roomUpdate = message.to === 'all'
|
|
193
|
+
await writeEvent({
|
|
194
|
+
schema: 'peertable.parent-watch-event.v1',
|
|
195
|
+
type: roomUpdate ? 'parent_room_update' : 'parent_dm',
|
|
196
|
+
parent,
|
|
197
|
+
seq: message.seq,
|
|
198
|
+
from: message.from,
|
|
199
|
+
to: message.to ?? null,
|
|
200
|
+
to_names: message.to_names ?? null,
|
|
201
|
+
body: roomUpdate ? ROOM_UPDATE_BODY : message.body,
|
|
202
|
+
message: roomUpdate
|
|
203
|
+
? { seq: message.seq, ts: message.ts, from: message.from, to: message.to }
|
|
204
|
+
: message,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
state = { ...state, last_seq: message.seq, last_event_at: now() }
|
|
208
|
+
saveState(state)
|
|
209
|
+
return matched
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function catchUp() {
|
|
213
|
+
if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') return true
|
|
214
|
+
const body = await readJson(`/messages?since=${state.last_seq}`)
|
|
215
|
+
for (const message of body.messages ?? []) {
|
|
216
|
+
if (await acceptMessage(message) && mode === '--next') return true
|
|
217
|
+
}
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Codex親のbackground task向け。HTTP catch-upを一度だけ行って即終了する。
|
|
222
|
+
// 長寿命なのはbackground taskのloopだけで、端末sessionやNode processは常駐させない。
|
|
223
|
+
if (mode === '--poll') {
|
|
224
|
+
await catchUp()
|
|
225
|
+
process.exit(0)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const nextWindowMs = Number(process.env.PEERTABLE_PARENT_WATCH_WINDOW_MS ?? 55_000)
|
|
229
|
+
if (mode === '--next' && (!Number.isFinite(nextWindowMs) || nextWindowMs < 100)) {
|
|
230
|
+
console.error('PARENT_WATCH_WINDOW_INVALID')
|
|
231
|
+
process.exit(2)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let consecutiveFailures = 0
|
|
235
|
+
for (;;) {
|
|
236
|
+
const deadline = mode === '--next' ? Date.now() + nextWindowMs : Number.POSITIVE_INFINITY
|
|
237
|
+
try {
|
|
238
|
+
if (await catchUp()) process.exit(0)
|
|
239
|
+
const controller = new AbortController()
|
|
240
|
+
const remaining = Number.isFinite(deadline) ? Math.max(1, deadline - Date.now()) : null
|
|
241
|
+
const timer = remaining === null ? null : setTimeout(() => controller.abort(), remaining)
|
|
242
|
+
try {
|
|
243
|
+
const response = await fetch(`${api}/events`, { signal: controller.signal })
|
|
244
|
+
if (!response.ok) throw new Error(`events ${response.status}`)
|
|
245
|
+
consecutiveFailures = 0
|
|
246
|
+
let buffer = ''
|
|
247
|
+
for await (const chunk of response.body) {
|
|
248
|
+
buffer += Buffer.from(chunk).toString('utf8')
|
|
249
|
+
const frames = buffer.split('\n\n')
|
|
250
|
+
buffer = frames.pop()
|
|
251
|
+
for (const frame of frames) {
|
|
252
|
+
const lines = frame.split('\n')
|
|
253
|
+
const eventName = lines.find(line => line.startsWith('event: '))?.slice(7).trim()
|
|
254
|
+
const data = lines.filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('\n')
|
|
255
|
+
if (eventName === 'ping') {
|
|
256
|
+
const head = Number(data)
|
|
257
|
+
if (await acceptLatticeState(readLatticeState(state.lattice)) && mode === '--next') process.exit(0)
|
|
258
|
+
if (Number.isSafeInteger(head) && head > state.last_seq && await catchUp()) process.exit(0)
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
if (eventName !== undefined && eventName !== 'message') continue
|
|
262
|
+
if (!data) continue
|
|
263
|
+
if (await acceptMessage(JSON.parse(data)) && mode === '--next') process.exit(0)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
throw new Error('events disconnected')
|
|
267
|
+
} finally {
|
|
268
|
+
if (timer !== null) clearTimeout(timer)
|
|
269
|
+
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (mode === '--next' && error.name === 'AbortError' && Date.now() >= deadline) process.exit(0)
|
|
272
|
+
consecutiveFailures += 1
|
|
273
|
+
if (consecutiveFailures >= 10) {
|
|
274
|
+
await writeEvent({
|
|
275
|
+
schema: 'peertable.parent-watch-event.v1',
|
|
276
|
+
type: 'watch_error',
|
|
277
|
+
parent,
|
|
278
|
+
code: 'PARENT_WATCH_UNREACHABLE',
|
|
279
|
+
detail: error.message,
|
|
280
|
+
})
|
|
281
|
+
process.exit(1)
|
|
282
|
+
}
|
|
283
|
+
if (mode === '--next' && Date.now() >= deadline) process.exit(0)
|
|
284
|
+
await sleep(Math.min(2000, Math.max(1, deadline - Date.now())))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 席の書込credentialを、秘密値をargv/envへ載せず作成・利用・撤去する境界。
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import {
|
|
5
|
+
chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync,
|
|
6
|
+
readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync,
|
|
7
|
+
} from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { join, relative, resolve } from 'node:path'
|
|
10
|
+
import { parsePostTokenEnvFile } from './seat-usage.mjs'
|
|
11
|
+
|
|
12
|
+
const fail = (code, message) => {
|
|
13
|
+
process.stderr.write(`${code}: ${message}\n`)
|
|
14
|
+
process.exit(1)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const credentialRoot = project => resolve(project, '.team', 'credentials')
|
|
18
|
+
const memberKey = member => createHash('sha256').update(member).digest('hex').slice(0, 12)
|
|
19
|
+
const credentialPath = (project, room, member) => {
|
|
20
|
+
const roomKey = createHash('sha256').update(room).digest('hex').slice(0, 12)
|
|
21
|
+
return join(credentialRoot(project), `${memberKey(member)}-${roomKey}.token`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const assertOwnedPath = (project, path) => {
|
|
25
|
+
const root = credentialRoot(project)
|
|
26
|
+
const target = resolve(path)
|
|
27
|
+
const rel = relative(root, target)
|
|
28
|
+
if (!rel || rel.startsWith('..') || rel.includes('/../'))
|
|
29
|
+
fail('SEAT_CREDENTIAL_PATH_INVALID', 'credential pathが対象projectの所有範囲外')
|
|
30
|
+
return { root, target }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const readCredential = path => {
|
|
34
|
+
let token
|
|
35
|
+
try { token = readFileSync(path, 'utf8').trim() } catch {
|
|
36
|
+
fail('SEAT_CREDENTIAL_UNREADABLE', 'credential fileを読めない')
|
|
37
|
+
}
|
|
38
|
+
if (!token) fail('SEAT_CREDENTIAL_INVALID', 'credential fileが空')
|
|
39
|
+
return token
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const [action, ...args] = process.argv.slice(2)
|
|
43
|
+
switch (action) {
|
|
44
|
+
case 'path': {
|
|
45
|
+
const [project, room, member] = args
|
|
46
|
+
if (!project || !room || !member) fail('SEAT_CREDENTIAL_ARGS_INVALID', 'path <project> <room> <member>')
|
|
47
|
+
process.stdout.write(credentialPath(project, room, member) + '\n')
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
case 'prepare': {
|
|
51
|
+
const [project, room, member] = args
|
|
52
|
+
if (!project || !room || !member) fail('SEAT_CREDENTIAL_ARGS_INVALID', 'prepare <project> <room> <member>')
|
|
53
|
+
const source = process.env.PEERTABLE_TOKEN_SOURCE_FILE
|
|
54
|
+
|| join(process.env.HOME || homedir(), '.config', 'peertable.env')
|
|
55
|
+
let sourceText
|
|
56
|
+
try { sourceText = readFileSync(source, 'utf8') } catch {
|
|
57
|
+
fail('SEAT_CREDENTIAL_SOURCE_UNREADABLE', '書込トークン設定fileを読めない')
|
|
58
|
+
}
|
|
59
|
+
const token = parsePostTokenEnvFile(sourceText)
|
|
60
|
+
if (!token) fail('SEAT_CREDENTIAL_TOKEN_MISSING', '書込トークン設定fileに値が無い')
|
|
61
|
+
|
|
62
|
+
const root = credentialRoot(project)
|
|
63
|
+
mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
64
|
+
chmodSync(root, 0o700)
|
|
65
|
+
const target = credentialPath(project, room, member)
|
|
66
|
+
for (const entry of readdirSync(root)) {
|
|
67
|
+
if (entry.startsWith(`${memberKey(member)}-`) && entry.endsWith('.token') && join(root, entry) !== target)
|
|
68
|
+
unlinkSync(join(root, entry))
|
|
69
|
+
}
|
|
70
|
+
const temporary = join(root, `.${process.pid}.${Date.now()}.tmp`)
|
|
71
|
+
let fd
|
|
72
|
+
try {
|
|
73
|
+
fd = openSync(temporary, 'wx', 0o600)
|
|
74
|
+
writeFileSync(fd, token + '\n', 'utf8')
|
|
75
|
+
fsyncSync(fd)
|
|
76
|
+
closeSync(fd)
|
|
77
|
+
fd = undefined
|
|
78
|
+
chmodSync(temporary, 0o600)
|
|
79
|
+
renameSync(temporary, target)
|
|
80
|
+
chmodSync(target, 0o600)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (fd !== undefined) closeSync(fd)
|
|
83
|
+
if (existsSync(temporary)) unlinkSync(temporary)
|
|
84
|
+
fail('SEAT_CREDENTIAL_PREPARE_FAILED', error.message)
|
|
85
|
+
}
|
|
86
|
+
process.stdout.write(target + '\n')
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
case 'request': {
|
|
90
|
+
const [credential, method, url, body = ''] = args
|
|
91
|
+
if (!credential || !method || !url) fail('SEAT_CREDENTIAL_ARGS_INVALID', 'request <credential> <method> <url> [json-body]')
|
|
92
|
+
const token = readCredential(credential)
|
|
93
|
+
let response
|
|
94
|
+
try {
|
|
95
|
+
response = await fetch(url, {
|
|
96
|
+
method,
|
|
97
|
+
headers: { 'Content-Type': 'application/json', 'X-Peertable-Token': token },
|
|
98
|
+
...(body ? { body } : {}),
|
|
99
|
+
})
|
|
100
|
+
} catch {
|
|
101
|
+
fail('SEAT_CREDENTIAL_REQUEST_UNREACHABLE', 'roomへ到達できない')
|
|
102
|
+
}
|
|
103
|
+
const responseBody = await response.text()
|
|
104
|
+
if (!response.ok) fail('SEAT_CREDENTIAL_REQUEST_FAILED', `HTTP ${response.status}`)
|
|
105
|
+
process.stdout.write(responseBody)
|
|
106
|
+
break
|
|
107
|
+
}
|
|
108
|
+
case 'remove': {
|
|
109
|
+
const [project, path] = args
|
|
110
|
+
if (!project || !path) fail('SEAT_CREDENTIAL_ARGS_INVALID', 'remove <project> <credential>')
|
|
111
|
+
const { root, target } = assertOwnedPath(project, path)
|
|
112
|
+
if (existsSync(target)) unlinkSync(target)
|
|
113
|
+
if (existsSync(root) && readdirSync(root).length === 0) rmdirSync(root)
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
default:
|
|
117
|
+
fail('SEAT_CREDENTIAL_ARGS_INVALID', 'prepare|path|request|remove を指定する')
|
|
118
|
+
}
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
// 確認ダイアログで停止した席が busy と `esc to interrupt` 不在から idle 側に誤判定され、
|
|
15
15
|
// 動けない席へ卓が代走を申し出るところまで進んだ。判定順は busy → blocked → idle
|
|
16
16
|
// (承認プロンプト表示中は `esc to interrupt` が消えるので、この順で正しい)
|
|
17
|
-
// dead = tmux セッションが無い/`pane_dead=1
|
|
17
|
+
// dead = tmux セッションが無い/`pane_dead=1`/画面は idle 風だが中の CLI プロセスが停止
|
|
18
|
+
// (`ps` stat 先頭 `T`。Lattice pull run の accept hold が attach 済み worker へ
|
|
19
|
+
// SIGSTOP を送る局面等。2026-08-11 実測)
|
|
18
20
|
// idle = 生きていて busy でも blocked でもない
|
|
19
21
|
//
|
|
20
22
|
// 送信は「変化した時」+「変化が無くても心拍」の2本立て。変化時だけだと、**bridge が死んだのか状態が
|
|
@@ -24,7 +26,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
24
26
|
import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
25
27
|
import { join } from 'node:path'
|
|
26
28
|
|
|
27
|
-
import { classifyPaneTail, decideBridgeContinuation, deriveMissingSession, parsePaneTokenHint, resolvePostToken, resolveSeatObservation, resolveTmuxSocket, supportsMemberObservation } from './seat-usage.mjs'
|
|
29
|
+
import { classifyPaneTail, decideBridgeContinuation, deriveMissingSession, isPaneProcessStopped, parsePaneTokenHint, resolvePostToken, resolveSeatObservation, resolveTmuxSocket, supportsMemberObservation, tmuxPanePid } from './seat-usage.mjs'
|
|
28
30
|
|
|
29
31
|
const args = process.argv.slice(2)
|
|
30
32
|
const proj = args[0]
|
|
@@ -108,7 +110,12 @@ function readSeat(member, previous, observedAt) {
|
|
|
108
110
|
const pane = tmux(target.socket, 'capture-pane', '-t', target.target, '-p')
|
|
109
111
|
if (pane === null) return { status: 'dead', busySince: null, paneTokenHint: null }
|
|
110
112
|
const tail = pane.split('\n').slice(-14).join('\n')
|
|
111
|
-
const
|
|
113
|
+
const tentativeStatus = classifyPaneTail(tail)
|
|
114
|
+
// pane 自体は生きたまま中の CLI プロセスだけが停止する局面(Lattice pull run の accept hold 等)を
|
|
115
|
+
// 拾う。画面の残像だけでは idle に誤判定するため、idle と読めた時だけ実プロセスの stat を見る。
|
|
116
|
+
const status = tentativeStatus === 'idle' && isPaneProcessStopped(tmuxPanePid(target.socket, target.target))
|
|
117
|
+
? 'dead'
|
|
118
|
+
: tentativeStatus
|
|
112
119
|
const busySince = status === 'busy' || status === 'blocked'
|
|
113
120
|
? ((previous?.status === 'busy' || previous?.status === 'blocked') && previous.busySince ? previous.busySince : observedAt)
|
|
114
121
|
: null
|
|
@@ -101,12 +101,16 @@ export function parsePostTokenEnvFile(text) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* launch-seat.sh:25-27
|
|
105
|
-
*
|
|
106
|
-
*
|
|
104
|
+
* launch-seat.sh:25-27 と同じ解決規則で書込トークンを決める(直接env → 席別credential → 設定file)。
|
|
105
|
+
* 規則を二重に書かない——常駐bridgeは起こし側のshell envを継承しないため、席別credentialのpathを
|
|
106
|
+
* 明示的に渡す。readFileは試験のための注入口で、既定は実ファイルを読む。
|
|
107
107
|
*/
|
|
108
108
|
export function resolvePostToken(env, readFile = path => { try { return readFileSync(path, 'utf8') } catch { return null } }) {
|
|
109
109
|
if (env.PEERTABLE_POST_TOKEN) return env.PEERTABLE_POST_TOKEN
|
|
110
|
+
if (env.PEERTABLE_CREDENTIAL_FILE) {
|
|
111
|
+
const token = readFile(env.PEERTABLE_CREDENTIAL_FILE)
|
|
112
|
+
return typeof token === 'string' ? token.trim() : ''
|
|
113
|
+
}
|
|
110
114
|
return parsePostTokenEnvFile(readFile(join(env.HOME || homedir(), '.config', 'peertable.env'))) ?? ''
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -153,6 +157,43 @@ export function classifyPaneTail(tail) {
|
|
|
153
157
|
return 'idle'
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
/**
|
|
161
|
+
* pane 内で実行中の CLI プロセスの pid(`#{pane_pid}`)を読む。tmux pane 自体は生きたまま
|
|
162
|
+
* (`pane_dead=0`)、中の CLI プロセスだけが停止する局面があるため、pane の生死とは別に
|
|
163
|
+
* このプロセス自身の状態を見る必要がある。
|
|
164
|
+
*/
|
|
165
|
+
export function tmuxPanePid(socket, target, exec = execFileSync) {
|
|
166
|
+
try {
|
|
167
|
+
const out = exec('tmux', ['-S', socket, 'list-panes', '-t', target, '-F', '#{pane_pid}'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
168
|
+
const pid = out.trim().split('\n')[0]
|
|
169
|
+
return pid && /^[0-9]+$/.test(pid) ? pid : null
|
|
170
|
+
} catch {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* pane プロセス(またはその直接の子)が stopped(`ps` stat の先頭が `T`)かどうかを見る。
|
|
177
|
+
* 実測(2026-08-11, bell/suzune): Lattice pull run の accept hold は attach 済み worker
|
|
178
|
+
* (pane の子で pid===pgid のもの)へ SIGSTOP を送る。tmux pane は shell も含めて生きたまま
|
|
179
|
+
* (`pane_dead=0`)で、末尾の画面は停止直前のまま残るため、`classifyPaneTail` だけでは
|
|
180
|
+
* `idle` と誤判定する。
|
|
181
|
+
*/
|
|
182
|
+
export function isPaneProcessStopped(panePid, exec = execFileSync) {
|
|
183
|
+
if (!panePid) return false
|
|
184
|
+
try {
|
|
185
|
+
const out = exec('ps', ['-o', 'pid=,ppid=,stat=', '-A'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
186
|
+
return out.split('\n').some((line) => {
|
|
187
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)/u.exec(line)
|
|
188
|
+
if (!match) return false
|
|
189
|
+
const [, pid, ppid, stat] = match
|
|
190
|
+
return (pid === String(panePid) || ppid === String(panePid)) && stat.startsWith('T')
|
|
191
|
+
})
|
|
192
|
+
} catch {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
156
197
|
/**
|
|
157
198
|
* paneのstatus行が公開しているtoken値だけを読む。
|
|
158
199
|
* vendor固有のログや課金単価は推測せず、表示が無い席はnullのままにする。
|
package/skill/scripts/setup.sh
CHANGED
|
@@ -51,6 +51,34 @@ else
|
|
|
51
51
|
mode=lattice
|
|
52
52
|
fi
|
|
53
53
|
|
|
54
|
+
# `.team/` は setup が所有する使い捨ての生成領域だが、既存 project の資産を
|
|
55
|
+
# その名前だけで所有物だと決めてはいけない。追跡済み file は project の意図した資産、
|
|
56
|
+
# 未追跡の残りは前卓の残骸か利用者の資産かを setup だけでは判定できない。
|
|
57
|
+
# どちらも write 前に typed conflict として止め、退避・削除・上書きはしない。
|
|
58
|
+
team_dir="$proj/.team"
|
|
59
|
+
team_existing=""
|
|
60
|
+
if [ -L "$team_dir" ] || { [ -e "$team_dir" ] && [ ! -d "$team_dir" ]; }; then
|
|
61
|
+
team_existing="$team_dir"
|
|
62
|
+
elif [ -d "$team_dir" ]; then
|
|
63
|
+
team_existing=$(find "$team_dir" -mindepth 1 -print | sort)
|
|
64
|
+
fi
|
|
65
|
+
team_tracked=""
|
|
66
|
+
if git -C "$proj" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
67
|
+
team_tracked=$(git -C "$proj" ls-files --cached -- .team)
|
|
68
|
+
fi
|
|
69
|
+
if [ -n "$team_existing" ] || [ -n "$team_tracked" ]; then
|
|
70
|
+
echo "PEERTABLE_SETUP_TEAM_CONFLICT: .team/ に既存資産があるため、setup は書き込まない" >&2
|
|
71
|
+
if [ -n "$team_tracked" ]; then
|
|
72
|
+
echo " tracked(project所有として扱い、上書きしない):" >&2
|
|
73
|
+
printf ' %s\n' "$team_tracked" >&2
|
|
74
|
+
fi
|
|
75
|
+
if [ -n "$team_existing" ]; then
|
|
76
|
+
echo " existing(前卓の残骸か利用者資産かを推測せず、触らない):" >&2
|
|
77
|
+
printf ' %s\n' "$team_existing" >&2
|
|
78
|
+
fi
|
|
79
|
+
exit 1
|
|
80
|
+
fi
|
|
81
|
+
|
|
54
82
|
# Lattice 併用モードは、公開CLIをprojectへ何か置く前に確定する。通常はglobal installされた
|
|
55
83
|
# lattice を使い、release前のsource treeを実測する時だけ `LATTICE_CLI` で同じtreeのbinを明示する。
|
|
56
84
|
# **work-order adapter binary はもう要らない**(配車を撤去したので登録しない)。ここで見るのは
|
|
@@ -74,13 +102,13 @@ if [ "$mode" = "lattice" ]; then
|
|
|
74
102
|
}
|
|
75
103
|
fi
|
|
76
104
|
|
|
77
|
-
mkdir -p "$tdir/roles"
|
|
105
|
+
mkdir -p "$tdir/roles" "$tdir/scripts"
|
|
78
106
|
cp "$tpl/charter.md" "$tdir/CLAUDE.md"
|
|
107
|
+
cp "$tpl/parent.md" "$tdir/roles/parent.md"
|
|
79
108
|
if [ "$mode" = "standalone" ]; then
|
|
80
109
|
cp "$tpl/member-standalone.md" "$tdir/roles/member.md"
|
|
81
110
|
cat "$tpl/tasks.md" "$tasks" > "$tdir/tasks.md"
|
|
82
111
|
else
|
|
83
|
-
mkdir -p "$tdir/scripts"
|
|
84
112
|
# claim 範囲は席へ渡す文書に焼き込む。範囲の出典を「誰かの記憶」でなく role 文書にする
|
|
85
113
|
if [ ${#phases[@]} -eq 0 ]; then
|
|
86
114
|
scope="この卓の claim 範囲は plan 全体(phase 指定なしで立っている)。"
|
|
@@ -90,7 +118,6 @@ else
|
|
|
90
118
|
sed -e "s|{{PLAN_KEY}}|$plan|g" -e "s|{{CLAIM_SCOPE}}|$scope|g" "$tpl/member.md" > "$tdir/roles/member.md"
|
|
91
119
|
cp "$tpl/done.sh" "$tdir/scripts/done.sh" && chmod +x "$tdir/scripts/done.sh"
|
|
92
120
|
fi
|
|
93
|
-
|
|
94
121
|
# room MCP 定義は project root の .mcp.json が正(channels は --mcp-config を解決しない。決定44)
|
|
95
122
|
added_root_mcp=false
|
|
96
123
|
if [ -f "$proj/.mcp.json" ]; then
|