peertable 0.8.35 → 0.8.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.8.35",
3
+ "version": "0.8.37",
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.35'
17
+ const MCP_VERSION = '0.8.37'
18
18
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
19
19
 
20
20
  const USAGE = `usage:
@@ -13,7 +13,10 @@
13
13
  // 実測(2026-08-17・Grok Build TUI): Grok 既定は follow_up_behavior=queue。素送信は
14
14
  // 今のターンへ混ざらず入力キューへ積まれ、次の user ターンになる。Grok 席だけ idle を待つ。
15
15
  import { execFile } from 'node:child_process'
16
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
16
+ import {
17
+ closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync,
18
+ unlinkSync, writeFileSync,
19
+ } from 'node:fs'
17
20
  import { join } from 'node:path'
18
21
  import { promisify } from 'node:util'
19
22
  import { resolvePostToken, resolveSeatObservation, tmuxArgv } from './seat-usage.mjs'
@@ -223,6 +226,7 @@ setInterval(beatBridge, 5_000) // 送信自体は BEAT_MS で間引く。失敗
223
226
  // 席ごとに未配達を溜めて、2秒ごとにまとめて1回起こす(連投で席を何度も起こさない)。
224
227
  // pending は room message の宛先名から作る。起動引数や harness は候補集合を決めない。
225
228
  const pending = new Map() // name -> Map<seq, message>
229
+ const stuckStreaks = new Map() // name -> { key: 'seq,seq', count }(DELIVERY_STUCK連続数。5回で打ち切り)
226
230
  const deliveryStates = new Map() // seq -> { message, targets, delivered }
227
231
  let seats = []
228
232
  let members = new Map()
@@ -500,6 +504,44 @@ async function wake(seat, msgs) {
500
504
  // Claude 席は「受理された兆候」を成功とみなし、回復キーに Escape を使わない。
501
505
  const CLAUDE_ACCEPTED = /paste again to expand|\[Pasted text|esc to interrupt|✻|✽|✳|·\s*\w+ing…/u
502
506
  const isClaude = memberHarness(member) === 'claude'
507
+ // Codex席の成立判定は画面でなくrollout transcript(席CODEX_HOMEの正本記録)で行う。
508
+ // 画面照合はTUIのレイアウト・描画タイミングごとに偽陽性/偽陰性を繰り返した(実被弾
509
+ // 2026-08-29に4系統: 会話履歴の残存誤検知・マーカー不在時のフォールバック誤検知・
510
+ // 受理済みキュー表示の誤検知・偽delivered)。submitされた本文はidle時ただちに
511
+ // rolloutへuser messageとして書かれるので、それだけを受理の証拠とする。
512
+ // 実行中ターン(esc to interrupt / to queue message)はCodexが入力をキューする実測
513
+ // (2026-08-08)に基づき受理扱いを維持する。
514
+ const codexSessionsDir = join(proj, '.team', 'seats', `${seat}.codex`, 'sessions')
515
+ const rolloutHasMarker = () => {
516
+ if (marker.length < 8) return false
517
+ let newest = null; let newestM = -Infinity
518
+ const walk = (dir) => {
519
+ let entries
520
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
521
+ for (const entry of entries) {
522
+ const file = join(dir, entry.name)
523
+ if (entry.isDirectory()) { walk(file); continue }
524
+ if (!entry.name.startsWith('rollout-') || !entry.name.endsWith('.jsonl')) continue
525
+ let m
526
+ try { m = statSync(file).mtimeMs } catch { continue }
527
+ if (m > newestM) { newestM = m; newest = file }
528
+ }
529
+ }
530
+ walk(codexSessionsDir)
531
+ if (newest === null) return false
532
+ let bytes
533
+ try {
534
+ const size = statSync(newest).size
535
+ const fd = openSync(newest, 'r')
536
+ const len = Math.min(size, 262144)
537
+ const buf = Buffer.alloc(len)
538
+ readSync(fd, buf, 0, len, size - len)
539
+ closeSync(fd)
540
+ bytes = buf.toString('utf8')
541
+ } catch { return false }
542
+ return bytes.replace(/\s+/gu, '').includes(marker)
543
+ }
544
+ const isCodex = memberHarness(member) === 'codex'
503
545
  for (let attempt = 0; attempt < 3; attempt++) {
504
546
  await sleep(1200)
505
547
  const after = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
@@ -510,11 +552,22 @@ async function wake(seat, msgs) {
510
552
  const tailSquashed = composerSquashed(String(after.stdout ?? ''))
511
553
  const queuedOrRunning = tailJoined.includes('esc to interrupt') || tailJoined.includes('to queue message')
512
554
  || (isClaude && CLAUDE_ACCEPTED.test(tailJoined))
513
- const stuck = (!isClaude && tailJoined.includes('esc dismiss'))
514
- || (marker.length >= 8 && tailSquashed.includes(marker) && !queuedOrRunning)
555
+ const stuck = isCodex
556
+ ? (!queuedOrRunning && !rolloutHasMarker())
557
+ : ((!isClaude && tailJoined.includes('esc dismiss'))
558
+ || (marker.length >= 8 && tailSquashed.includes(marker) && !queuedOrRunning))
515
559
  if (!stuck) break
516
560
  if (attempt === 2) {
517
- log(`DELIVERY_STUCK: ${seat} の入力欄に本文が残ったまま送信できない(popup/入力詰まり)。受領を確定せず次周期に再試行する`)
561
+ // 蓄積戦争の禁止: 次周期は束ねる本文が変わり得て残存照合が外れ、全文再打鍵が
562
+ // composer を多重本文で埋める(実被弾 2026-08-29 に2回)。STUCKで周期を明け渡す前に
563
+ // composer を行数分の C-u で空にし、次周期が常に「空の入力欄へ1部だけ」から始まる
564
+ // 構造にする。
565
+ const wipePane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
566
+ const wipeLines = Math.min(120, String(wipePane.stdout ?? '').split('\n').length + 10)
567
+ for (let i = 0; i < wipeLines; i++) {
568
+ await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'C-u'], { socket: observation.socket }))
569
+ }
570
+ log(`DELIVERY_STUCK: ${seat} の入力欄に本文が残ったまま送信できない(popup/入力詰まり)。入力欄を掃除し、受領を確定せず次周期に再試行する`)
518
571
  const error = new Error(`DELIVERY_STUCK: ${seat}`)
519
572
  error.code = 'DELIVERY_STUCK'
520
573
  throw error
@@ -650,6 +703,22 @@ async function flushSeat(seat) {
650
703
  // 再試行が成立すれば同じ (seq, recipient) を delivered で上書きする
651
704
  const result = ['SEAT_TUI_GONE', 'MEMBER_MISSING', 'DESCRIPTOR_MISSING'].includes(code) ? 'seat_unavailable' : 'failed'
652
705
  for (const msg of msgs) await postReceipt(msg.seq, seat, result, code)
706
+ // 同一集合のSTUCKが5周期続いたら再試行を打ち切る。failed receiptと親宛[配達失敗]DMは
707
+ // 既に出ており、無限の打鍵再試行は席のcomposerを汚し続けるだけで誰も救わない。
708
+ if (code === 'DELIVERY_STUCK') {
709
+ const key = msgs.map((msg) => msg.seq).join(',')
710
+ const prev = stuckStreaks.get(seat)
711
+ const count = prev?.key === key ? prev.count + 1 : 1
712
+ stuckStreaks.set(seat, { key, count })
713
+ if (count >= 5) {
714
+ const queue = pending.get(seat)
715
+ if (queue) for (const msg of msgs) queue.delete(msg.seq)
716
+ stuckStreaks.delete(seat)
717
+ log(`DELIVERY_ABANDONED: ${seat} への seq ${key} は5周期連続STUCKのため再試行を打ち切る(receipt=failed・親へ通知済み)`)
718
+ }
719
+ } else {
720
+ stuckStreaks.delete(seat)
721
+ }
653
722
  } finally {
654
723
  flushing.delete(seat)
655
724
  }