peertable 0.8.28 → 0.8.30

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 CHANGED
@@ -113,9 +113,9 @@ Windows工場hostはPowerShell 7(`pwsh.exe`)を前提とし、5.1しかな
113
113
 
114
114
  動いており、**自分自身の開発に使っている**。2026-08-08 に end-to-end 検証済み——オーケストレーターなしの完全な一周(2 メンバーが相談し、claim し、インターフェースを交渉し、見つけた罠を共有して小さなプロジェクトを出荷)を**外部介入ゼロ**で完走。2026-08-13の実席ライフサイクルでは、作業席が親を通じてsession contextを保ったままmodel / effortを変更し、再起動後はroomと工程正本から再着任した。2026-08-14にはGrok 4.6席の着席、room参加、同一sessionの4.6↔4.5変更、DM起床を実機で確認した。2026-08-17にGrok席はidle待ち、broadcastは本文を残し、tmuxの無い親でbridge cursorが止まらないよう直した。
115
115
 
116
- 現在のnpm releaseは **peertable 0.7.1**。
116
+ 現在のnpm releaseは **peertable 0.8.30**。
117
117
 
118
- 設計文書と決定履歴(**106 決定**)は [docs/plan.md](docs/plan.md)。
118
+ 設計文書と決定履歴(**129 決定**)は [docs/plan.md](docs/plan.md)。
119
119
 
120
120
  Claude Code channels はリサーチプレビューのため、フラグ・プロトコルは変わりうる。
121
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.8.28",
3
+ "version": "0.8.30",
4
4
  "description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code, Codex, and Grok sessions into a team of equal, long-lived peers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/room/client.mjs CHANGED
@@ -14,7 +14,7 @@ import { boundedRecent, boundedUnread } from './message-bounds.mjs'
14
14
 
15
15
  // client.mjs 側のハードコード版数。package.json の version と一致していることを
16
16
  // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
17
- const MCP_VERSION = '0.8.28'
17
+ const MCP_VERSION = '0.8.30'
18
18
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
19
19
 
20
20
  const USAGE = `usage:
package/room/server.mjs CHANGED
@@ -235,6 +235,40 @@ function effectiveStatus(member, bridges, now = Date.now()) {
235
235
  }
236
236
  }
237
237
 
238
+ // 状態点とは別に、閲覧者が「いま何をしているか」を読める短い表示をserverで一元生成する。
239
+ // idle/dead/blocked は過去のmissionや発言を現在形で再表示しない。busyだけ、本人の最新の
240
+ // [次の行動] / [claim] / [引受] / [作業中] を現在作業として採る。
241
+ const ACTIVITY_PREFIX = /^\[(?:次の行動|claim|引受|作業中)\]\s*/i
242
+ function compactActivity(body) {
243
+ const plain = String(body).replace(ACTIVITY_PREFIX, '').replace(/\s+/g, ' ').trim()
244
+ const chars = [...plain]
245
+ return chars.length <= 120 ? plain : `${chars.slice(0, 119).join('')}…`
246
+ }
247
+ function readActivityMessages(room) {
248
+ if (!existsSync(room.logPath)) return { messages: [], readable: true }
249
+ let readable = true
250
+ const messages = []
251
+ for (const line of readFileSync(room.logPath, 'utf8').split('\n').filter(Boolean)) {
252
+ try { messages.push(JSON.parse(line)) } catch { readable = false }
253
+ }
254
+ return { messages, readable }
255
+ }
256
+ function memberActivity(member, effective, activityLog) {
257
+ const status = effective.status_effective
258
+ if (status === 'idle') return { activity_text: '待機中', activity_at: null }
259
+ if (status === 'dead') return { activity_text: 'セッション停止', activity_at: null }
260
+ if (status === 'blocked') return { activity_text: '承認操作待ち', activity_at: null }
261
+ if (status !== 'busy') return { activity_text: '稼働状態を確認できません', activity_at: null }
262
+ for (let index = activityLog.messages.length - 1; index >= 0; index--) {
263
+ const message = activityLog.messages[index]
264
+ if (message.from !== member.name || typeof message.body !== 'string' || !ACTIVITY_PREFIX.test(message.body)) continue
265
+ const text = compactActivity(message.body)
266
+ if (text) return { activity_text: text, activity_at: message.ts ?? null }
267
+ }
268
+ if (!activityLog.readable) return { activity_text: '作業情報を取得できません', activity_at: null }
269
+ return { activity_text: '作業内容未報告', activity_at: null }
270
+ }
271
+
238
272
  // ---- 配送状態の導出(決定102)-----------------------------------------------------
239
273
  // receipt(wakeup-bridge の実投入記録)が正。無い宛先は member 台帳と bridge 台帳から
240
274
  // pending / seat_unavailable / bridge_unavailable を導出する。room_saved だけでは配達と言わない。
@@ -353,12 +387,16 @@ http.createServer(async (req, res) => {
353
387
  if (req.method === 'GET' && rest === 'members') {
354
388
  const now = Date.now()
355
389
  const bridges = bridgeHealth(room.name, now)
390
+ const activityLog = readActivityMessages(room)
356
391
  return json(res, 200, {
357
- members: listMembers(room.name).map(m => ({ ...m, ...effectiveStatus(m, bridges, now) })),
392
+ members: listMembers(room.name).map(m => {
393
+ const effective = effectiveStatus(m, bridges, now)
394
+ return { ...m, ...effective, ...memberActivity(m, effective, activityLog) }
395
+ }),
358
396
  bridges,
359
397
  capabilities: {
360
398
  member_observation_v1: true, member_ledger_v1: true,
361
- effective_status_v1: true, delivery_receipt_v1: true,
399
+ effective_status_v1: true, member_activity_v1: true, delivery_receipt_v1: true,
362
400
  },
363
401
  }, CORS)
364
402
  }
@@ -524,12 +562,14 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
524
562
  <title>${esc(room)} · Peertable</title>${FAVICON}<style>${STYLE}
525
563
  .top{position:sticky;top:0;z-index:2;background:var(--bg);border-bottom:1px solid var(--line);padding:12px 16px 0}
526
564
  .top>div{max-width:760px;margin:0 auto}
527
- .members{display:flex;gap:6px;overflow-x:auto;padding:10px 0;scrollbar-width:thin}
565
+ .members{display:flex;gap:8px;overflow-x:auto;padding:10px 0;scrollbar-width:thin}
528
566
  .bridgewarn{color:var(--busy);font-size:12px;font-weight:650;padding:0 0 8px}
529
567
  .bridgewarn:empty{display:none}
530
568
  .chip.has-meta{cursor:pointer}
531
569
  /* 稼働状態の点。報告が途絶えたら unknown(中空)へ落として、古い状態を出し続けない */
532
570
  .chip .nm{display:inline-flex;align-items:center;gap:5px}
571
+ .chip .state-label{font-size:10px;font-weight:700;color:var(--dim);white-space:nowrap}
572
+ .chip .activity{display:block;max-width:190px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dim);font-size:10px;font-weight:500}
533
573
  .chip .st{flex:none;display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--dim)}
534
574
  .chip .st.busy{background:var(--busy)}
535
575
  .chip .st.idle{background:var(--idle)}
@@ -539,7 +579,7 @@ const UI = room => `<!doctype html><html lang="ja"><head><meta charset="utf-8"><
539
579
  .metapop{position:fixed;z-index:20;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-size:12px;box-shadow:0 6px 20px rgba(0,0,0,.18);max-width:70vw}
540
580
  .metapop .metaname{font-weight:600;margin-bottom:2px}
541
581
  .metapop .metaline{color:var(--dim)}
542
- .chip{position:relative;flex:none;display:flex;align-items:center;gap:7px;padding:5px 11px 5px 5px;border:1px solid var(--line);border-radius:12px;background:var(--surface);font-size:12px;font-weight:600}
582
+ .chip{position:relative;flex:none;display:flex;align-items:center;gap:7px;min-width:150px;max-width:230px;padding:6px 11px 6px 6px;border:1px solid var(--line);border-radius:12px;background:var(--surface);font-size:12px;font-weight:600}
543
583
  .chip .av{width:22px;height:22px;font-size:11px;flex:none}
544
584
  .chip .id{display:flex;flex-direction:column;gap:1px;min-width:0;line-height:1.25}
545
585
  .chip.recent{border-color:hsl(var(--h) var(--sat) var(--edge))}
@@ -755,8 +795,10 @@ async function refreshMembers(){
755
795
  // 閲覧面ごとに独自の鮮度判定を持つと、MCP と Web UI で違う判定になり誤認が再発する
756
796
  const st=m.status_effective??null
757
797
  if(st)c.classList.add('is-'+st)
798
+ const stateText=st?({busy:'作業中',idle:'待機',dead:'停止',blocked:'承認待ち',unknown:'不明'}[st]??st):'不明'
758
799
  const reason={status_unreported:'未報告(seat-status bridge が動いていない)',status_heartbeat_stale:'状態heartbeatが途絶えている',status_bridge_down:'状態bridgeが停止(status_bridge_down)',status_bridge_unreported:'状態bridgeが未登録',bridge_auth_failed:'bridge認証失敗(403)'}[m.status_reason]
759
- meta.push(st?'状態 '+({busy:'作業中',idle:'待機',dead:'停止',blocked:'承認待ち',unknown:'不明'}[st]??st)+(reason?'('+reason+')':''):'状態 未取得(旧serverは実効状態を返さない)')
800
+ meta.push(st?'状態 '+stateText+(reason?'('+reason+')':''):'状態 未取得(旧serverは実効状態を返さない)')
801
+ meta.push('現在 '+(m.activity_text??'未取得(旧server)'))
760
802
  const usage=[]
761
803
  const busyAge=m.busy_since?Date.now()-Date.parse(m.busy_since):NaN
762
804
  if((st==='busy'||st==='blocked')&&Number.isFinite(busyAge)&&busyAge>=0)usage.push('継続 '+elapsed(busyAge))
@@ -766,12 +808,12 @@ async function refreshMembers(){
766
808
  c.appendChild(el('span','av',initial(m.name)))
767
809
  const id=el('span','id')
768
810
  const nameRow=el('span','nm',m.name)
769
- // チップの常設表示は「アバター・名前・◉」だけ。◉は状態が新鮮な時だけ色が付き、
770
- // 途絶・未報告は中空リング=bridge が動いていないことがひと目で分かる。
771
- // roles / settings / mission は title(ホバー)とタップ popover にだけ出す。
772
811
  nameRow.appendChild(el('span','st '+(st??'unknown')))
812
+ nameRow.appendChild(el('span','state-label',stateText))
773
813
  id.appendChild(nameRow)
814
+ id.appendChild(el('span','activity',m.activity_text??'現在作業未取得'))
774
815
  c.appendChild(id)
816
+ c.setAttribute('aria-label',m.name+'、'+stateText+'、'+(m.activity_text??'現在作業未取得'))
775
817
  // タップ環境には hover が無いので、押した時に同じ内容を出す(ホバーは title が担う)
776
818
  if(meta.length){c.classList.add('has-meta');c.addEventListener('click',ev=>{ev.stopPropagation();showMeta(c,m,meta)})}
777
819
  membersEl.appendChild(c)
@@ -821,6 +863,8 @@ function celebrate(name){
821
863
  }
822
864
  const BEAT=${HEARTBEAT_MS}
823
865
  let lastSeq=0,lastBeat=Date.now(),es=null,emptyEl=null,firstLoad=true,catching=false,memberDebounce=null
866
+ const scheduleMemberRefresh=()=>{clearTimeout(memberDebounce);memberDebounce=setTimeout(refreshMembers,150)}
867
+ const isActivityMessage=m=>m.from!=='system'&&typeof m.body==='string'&&/^(?:\[(?:次の行動|claim|引受|作業中)\])/i.test(m.body)
824
868
  // seq で二重描画を弾く。張り直し後の追いつきと SSE の新着が重なっても同じ発言は1回しか出ない
825
869
  function apply(m,live=false){
826
870
  if(m.seq<=lastSeq)return false
@@ -859,12 +903,12 @@ function connect(){
859
903
  // 場合は、この差分だけが手掛かりになる
860
904
  es.addEventListener('ping',e=>{lastBeat=Date.now();if(Number(e.data)>lastSeq)catchUp()})
861
905
  // 稼働状態・素性の変化。既存の refreshMembers() を150msデバウンスで呼ぶ(部分更新は実装しない)
862
- es.addEventListener('member',()=>{clearTimeout(memberDebounce);memberDebounce=setTimeout(refreshMembers,150)})
906
+ es.addEventListener('member',scheduleMemberRefresh)
863
907
  es.onmessage=e=>{
864
908
  lastBeat=Date.now()
865
909
  const m=JSON.parse(e.data),stick=nearBottom()
866
910
  if(!apply(m,true))return
867
- if(m.from==='system')refreshMembers();else{markActive(m.from);if(isCompletion(m))celebrate(m.from)}
911
+ if(m.from==='system')refreshMembers();else{markActive(m.from);if(isActivityMessage(m))scheduleMemberRefresh();if(isCompletion(m))celebrate(m.from)}
868
912
  if(stick)window.scrollTo(0,document.body.scrollHeight)
869
913
  syncToBottom()
870
914
  }
@@ -347,50 +347,56 @@ async function wake(seat, msgs) {
347
347
  // shell へ room の本文を send-keys すると、本文がそのまま shell コマンドとして実行される
348
348
  // (実被弾 2026-08-22: codex 終了後の bash へ配達され `command not found` が走った。
349
349
  // 本文次第では席の権限で任意コマンドになる)。配達は止め、毎周期 typed log で叫ぶ。
350
- // **pane_current_command は「bash の下で agent CLI が走る」形を bash と報告することがある**
351
- // (実被弾 2026-08-22: 生きている codex 席が bash 判定になり配達が全停止、席が起きなかった)。
352
- // shell 名だった時は pane 配下の子孫を辿り、**当該 harness CLI プロセスが実在する時だけ**配達する。
353
- // 「子孫がゼロかどうか」では足りない——agent CLI が死んでも、CLI が起こした背景ターミナル等の
354
- // 子孫が pane shell の下に生き残り、防御をすり抜けて room 本文が bash で実行された
355
- // (実被弾 2026-08-29: codex 死亡後の pane へ broadcast が配達され `command not found` が走った)。
356
- const fg = await run('tmux', tmuxArgv(['display-message', '-p', '-t', observation.target, '#{pane_current_command}'], { socket: observation.socket }))
357
- const fgCommand = String(fg.stdout ?? '').trim()
358
- if (['bash', 'zsh', 'sh', 'dash', 'fish', 'tcsh', 'csh', 'ksh'].includes(fgCommand)) {
359
- const harness = memberHarness(member)
360
- const panePidOut = await run('tmux', tmuxArgv(['display-message', '-p', '-t', observation.target, '#{pane_pid}'], { socket: observation.socket }))
361
- const panePid = Number(String(panePidOut.stdout ?? '').trim())
362
- let harnessAlive = false
363
- if (Number.isSafeInteger(panePid) && panePid > 0 && typeof harness === 'string' && harness) {
364
- const psOut = await run('/bin/ps', ['-axo', 'pid=,ppid=,command='], { env: { ...process.env, LC_ALL: 'C' } })
365
- const children = new Map()
366
- const commands = new Map()
350
+ // **打鍵が届く先は pane tty の前面プロセスグループだけ**なので、配達可否はそれで判定する。
351
+ // 「前面コマンド名がshellか」(実被弾 2026-08-22: bash 配下で生きる codex を誤遮断)でも、
352
+ // 「pane 子孫に agent CLI が実在するか」(実被弾 2026-08-29: SIGTSTP で停止した codex は
353
+ // 実在するが打鍵は bash が受け、room 本文がコマンド実行された)でも足りない。
354
+ // ps STAT `+` tty の前面プロセスグループを OS が直接教える印であり、
355
+ // 死亡・停止(T)・背面化のすべてを一つの条件で塞ぐ。
356
+ const ttyOut = await run('tmux', tmuxArgv(['display-message', '-p', '-t', observation.target, '#{pane_tty}'], { socket: observation.socket }))
357
+ const paneTty = String(ttyOut.stdout ?? '').trim().replace(/^\/dev\//u, '')
358
+ const SHELLS = ['bash', 'zsh', 'sh', 'dash', 'fish', 'tcsh', 'csh', 'ksh']
359
+ const ttyState = async () => {
360
+ let foreground = false
361
+ let stopped = false
362
+ let label = '(不明)'
363
+ if (paneTty) {
364
+ const psOut = await run('/bin/ps', ['-t', paneTty, '-o', 'stat=,command='], { env: { ...process.env, LC_ALL: 'C' } })
367
365
  for (const line of String(psOut.stdout ?? '').split('\n')) {
368
- const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/u)
366
+ const m = line.trim().match(/^(\S+)\s+(.*)$/u)
369
367
  if (!m) continue
370
- const pid = Number(m[1]); const ppid = Number(m[2])
371
- if (!children.has(ppid)) children.set(ppid, [])
372
- children.get(ppid).push(pid)
373
- commands.set(pid, m[3])
374
- }
375
- const queue = [...(children.get(panePid) ?? [])]
376
- while (queue.length) {
377
- const pid = queue.pop()
378
- const argv0 = String(commands.get(pid) ?? '').split(/\s+/u, 1)[0]
379
- const base = argv0.split('/').pop()
380
- if (base === harness || (harness === 'claude' && base === 'node' && String(commands.get(pid) ?? '').includes('claude'))) {
381
- harnessAlive = true
382
- break
368
+ const base = String(m[2]).split(/\s+/u, 1)[0].split('/').pop()
369
+ if (SHELLS.includes(base)) {
370
+ if (m[1].includes('+')) label = base
371
+ continue
383
372
  }
384
- queue.push(...(children.get(pid) ?? []))
373
+ if (m[1].includes('+')) foreground = true
374
+ if (m[1].startsWith('T')) stopped = true
385
375
  }
386
376
  }
387
- if (!harnessAlive) {
388
- log(`SEAT_TUI_GONE: ${seat} の pane(前面 ${fgCommand})の子孫に ${memberHarness(member) ?? '?'} CLI が見つからない=agent 終了済み。`
389
- + 'shell へのコマンド実行を防ぐため配達しない。席を立て直すか leave-seat で畳むこと')
390
- const error = new Error(`SEAT_TUI_GONE: ${seat}`)
391
- error.code = 'SEAT_TUI_GONE'
392
- throw error
393
- }
377
+ return { foreground, stopped, label }
378
+ }
379
+ let tty = await ttyState()
380
+ if (!tty.foreground && tty.stopped) {
381
+ // Codex CLI の job control 欠陥(openai/codex#37088 系)で TUI が SIGTSTP/SIGTTIN 停止し、
382
+ // 打鍵が shell へ落ちる形が反復した(実被弾 2026-08-29 に2席×複数回)。配達の前提を
383
+ // 自動で回復する: pane の前面 shell へ `fg` を送って停止 job を前面へ戻し、成立を再観測する。
384
+ log(`SEAT_TUI_STOPPED: ${seat} の agent が停止(T)状態。fg で前面へ蘇生を試みる`)
385
+ await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'C-u'], { socket: observation.socket }))
386
+ await run('tmux', tmuxArgv(['send-keys', '-l', '-t', observation.target, 'fg'], { socket: observation.socket }))
387
+ await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
388
+ await sleep(2000)
389
+ tty = await ttyState()
390
+ if (tty.foreground) log(`SEAT_TUI_RESUMED: ${seat} の agent を前面へ復帰させた`)
391
+ }
392
+ if (!tty.foreground) {
393
+ const foregroundLabel = tty.label
394
+ log(`SEAT_TUI_GONE: ${seat} の pane tty 前面が agent CLI でない(前面: ${foregroundLabel})=`
395
+ + '打鍵は shell に落ちる(agent 死亡・SIGTSTP 停止・背面化)。'
396
+ + 'shell へのコマンド実行を防ぐため配達しない。席を立て直すか leave-seat で畳むこと')
397
+ const error = new Error(`SEAT_TUI_GONE: ${seat}`)
398
+ error.code = 'SEAT_TUI_GONE'
399
+ throw error
394
400
  }
395
401
  if (await passKnownCodexDialog(member)) return 'deferred'
396
402
  const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))