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.
@@ -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
- member=$(python3 - "$name" "$model" "$effort" <<'PY'
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:4]
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 status = classifyPaneTail(tail)
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 と同じ解決規則で書込トークンを決める(env が先・無ければ設定ファイル)。
105
- * 規則を二重に書かない——bash 側だけが自力解決を持ち、.mjs 側が起こす側の env に丸投げしていたのが
106
- * 上記 403 の構造的な原因だった。readFile は試験のための注入口で、既定は実ファイルを読む。
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のままにする。
@@ -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