peertable 0.8.22 → 0.8.25

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.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +128 -128
  3. package/README.md +164 -164
  4. package/package.json +48 -47
  5. package/room/Dockerfile +7 -7
  6. package/room/client.mjs +17 -6
  7. package/room/message-bounds.mjs +55 -0
  8. package/skill/02_models.snapshot.md +103 -103
  9. package/skill/SKILL.md +265 -265
  10. package/skill/launchd/dev.kitepon.peertable-bridges.plist +20 -20
  11. package/skill/scripts/agent-pane-status.mjs +6 -0
  12. package/skill/scripts/alarm-bridge.mjs +28 -11
  13. package/skill/scripts/alarm-condition.mjs +7 -0
  14. package/skill/scripts/alarm-set.sh +22 -10
  15. package/skill/scripts/alarm-write.mjs +27 -0
  16. package/skill/scripts/archive-room-log.py +0 -0
  17. package/skill/scripts/bridge-record-live.mjs +0 -0
  18. package/skill/scripts/change-effort.sh +0 -0
  19. package/skill/scripts/change-seat.sh +0 -0
  20. package/skill/scripts/claude-dialog.mjs +4 -1
  21. package/skill/scripts/codex-dialog.mjs +18 -5
  22. package/skill/scripts/codex-parent-watch.sh +0 -0
  23. package/skill/scripts/doctor.sh +0 -0
  24. package/skill/scripts/ensure-all-bridges.sh +0 -0
  25. package/skill/scripts/ensure-bridge.sh +19 -6
  26. package/skill/scripts/ensure-codex-room-mcp.mjs +0 -0
  27. package/skill/scripts/ensure-project-runtime.sh +13 -0
  28. package/skill/scripts/ensure-room-mcp.mjs +0 -0
  29. package/skill/scripts/external-pane.mjs +0 -0
  30. package/skill/scripts/grok-seat-config.mjs +47 -0
  31. package/skill/scripts/kickoff-gate.mjs +0 -0
  32. package/skill/scripts/launch-seat.sh +74 -65
  33. package/skill/scripts/leave-seat.sh +0 -0
  34. package/skill/scripts/make-plan-input.mjs +0 -0
  35. package/skill/scripts/parent-join.sh +0 -0
  36. package/skill/scripts/parent-watch.mjs +2 -3
  37. package/skill/scripts/platform/windows/build-bridge-command.mjs +7 -2
  38. package/skill/scripts/resolve-seat-placement.mjs +0 -0
  39. package/skill/scripts/resume.sh +2 -9
  40. package/skill/scripts/runtime-contract.test.mjs +158 -0
  41. package/skill/scripts/runtime-digest.mjs +25 -0
  42. package/skill/scripts/seat-status-bridge.mjs +2 -2
  43. package/skill/scripts/seat-usage.mjs +8 -0
  44. package/skill/scripts/set-mission.sh +0 -0
  45. package/skill/scripts/setup.sh +3 -6
  46. package/skill/scripts/teardown.sh +0 -0
  47. package/skill/scripts/tmux-at.bash +22 -22
  48. package/skill/scripts/upgrade-team-assets.sh +4 -0
  49. package/skill/scripts/vendors/grok/pane-status.mjs +7 -0
  50. package/skill/scripts/wakeup-bridge.mjs +36 -10
  51. package/skill/templates/charter.md +20 -20
  52. package/skill/templates/mcp.json +5 -5
  53. package/skill/templates/member-standalone.md +60 -60
  54. package/skill/templates/member.md +159 -159
  55. package/skill/templates/parent.md +141 -141
  56. package/skill/templates/tasks.md +8 -8
@@ -98,11 +98,10 @@ brief_file=""
98
98
  brief_max_bytes=65536
99
99
  brief_completed=false
100
100
  brief_dispatched=false
101
+ brief_sent=false
102
+ brief_event_cursor=""
101
103
  seat_created=false
102
104
  rollback_done=false
103
- # ready を観測できないだけの不確実性は、投入後の真失敗と分ける。これは
104
- # Aiterm から手動 dispatch できる空席として残し、席数へ成功着任とは数えない。
105
- brief_not_ready=false
106
105
  sock=""
107
106
  sess=""
108
107
  url=""
@@ -177,7 +176,7 @@ on_exit() {
177
176
  local rollback_rc
178
177
  trap - EXIT
179
178
  cleanup_brief
180
- if [ "$seat_created" = true ] && [ "$brief_completed" != true ] && [ "$brief_not_ready" != true ] && [ "$rollback_done" != true ]; then
179
+ if [ "$seat_created" = true ] && [ "$brief_completed" != true ] && [ "$rollback_done" != true ]; then
181
180
  rollback_done=true
182
181
  if rollback_brief "$exit_rc"; then
183
182
  :
@@ -214,6 +213,10 @@ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
214
213
  fi
215
214
  fi
216
215
 
216
+ # 既存卓も正規着座入口の一回で現行generated assetへ収束させる。alarm輸送helperや
217
+ # member規約の更新を利用側の事前resume順序へ委ねない。
218
+ "$peertable_script_dir/upgrade-team-assets.sh" "$proj"
219
+
217
220
  # **画面の文字列は「model が使えること」を意味しない。** 2026-08-11 実測: fable-5 の席は
218
221
  # Claude の channels バナーが出たので下の着席判定を通ったが、その後の入力は全て
219
222
  # model unavailable で 0 秒失敗し、席は一度も仕事をできなかった(room [11])。
@@ -241,7 +244,7 @@ case "$harness" in
241
244
  exit 1
242
245
  fi
243
246
  cp "${HOME}/.grok/auth.json" "${grok_home}/auth.json"
244
- printf '%s\n' '[ui]' 'permission_mode = "always-approve"' >"${grok_home}/config.toml"
247
+ node "$peertable_script_dir/grok-seat-config.mjs" "$proj" "${grok_home}/config.toml"
245
248
  echo "grok preflight: GROK_HOME=${grok_home} ${grok_bin} --model ${model} --reasoning-effort ${effort} -p ping"
246
249
  preflight_cmd=(env GROK_HOME="$grok_home" "$grok_bin" --model "$model" --reasoning-effort "$effort" -p "ping")
247
250
  ;;
@@ -346,7 +349,7 @@ if [ "$harness" = grok ]; then
346
349
  fi
347
350
  cp "${HOME}/.grok/auth.json" "${grok_home}/auth.json"
348
351
  chmod 600 "${grok_home}/auth.json" 2>/dev/null || true
349
- printf '%s\n' '[ui]' 'permission_mode = "always-approve"' >"${grok_home}/config.toml"
352
+ node "$peertable_script_dir/grok-seat-config.mjs" "$proj" "${grok_home}/config.toml"
350
353
  fi
351
354
 
352
355
  # 前の卓の残骸を回収してから、Aiterm の公開 agent launcher を唯一の席起動経路として呼ぶ。
@@ -435,9 +438,14 @@ print('dispatched' if cursor is not None and residue is not True
435
438
  PY
436
439
  )
437
440
  case "$brief_receipt_state" in
438
- dispatched) brief_dispatched=true ;;
441
+ dispatched)
442
+ brief_dispatched=true
443
+ brief_sent=true
444
+ brief_event_cursor=$(python3 -c 'import json,sys;print(json.loads(sys.argv[1]).get("event_cursor", ""))' "$launch_receipt")
445
+ ;;
439
446
  residue)
440
447
  brief_in_composer=true
448
+ brief_event_cursor=$(python3 -c 'import json,sys;print(json.loads(sys.argv[1]).get("event_cursor", ""))' "$launch_receipt")
441
449
  echo "SEAT_BRIEF_SUBMIT_RESIDUE: brief は入力欄に残り submit が落ちた。着席確認後に submit し直す" >&2
442
450
  ;;
443
451
  *)
@@ -476,31 +484,29 @@ claude_trust_accepted=false
476
484
  codex_dialog_helper="$peertable_script_dir/codex-dialog.mjs"
477
485
  claude_dialog_helper="$peertable_script_dir/claude-dialog.mjs"
478
486
  pass_codex_pane() {
479
- local json key
480
- json=$(printf '%s' "$1" | node "$codex_dialog_helper" || true)
481
- [ -n "$json" ] && [ "$json" != "null" ] || return 1
487
+ local keys key
488
+ keys=$(printf '%s' "$1" | node "$codex_dialog_helper" --keys || true)
489
+ [ -n "$keys" ] || return 1
490
+ sleep .3
482
491
  while IFS= read -r key; do
483
492
  [ -n "$key" ] || continue
484
493
  tmux_at send-keys -t "$sess" "$key" || return 1
485
494
  sleep .3
486
- done < <(python3 -c 'import json,sys
487
- a=json.loads(sys.stdin.read() or "null")
488
- print("\n".join(a.get("keys") or []) if isinstance(a, dict) else "")' <<<"$json")
495
+ sleep .3
496
+ done <<<"$keys"
489
497
  return 0
490
498
  }
491
499
  codex_pane_blocks_ready() {
492
500
  printf '%s' "$1" | node "$codex_dialog_helper" --ready-ok
493
501
  }
494
502
  pass_claude_mcp_pane() {
495
- local json key
496
- json=$(printf '%s' "$1" | node "$claude_dialog_helper" || true)
497
- [ -n "$json" ] && [ "$json" != "null" ] || return 1
503
+ local keys key
504
+ keys=$(printf '%s' "$1" | node "$claude_dialog_helper" --keys || true)
505
+ [ -n "$keys" ] || return 1
498
506
  while IFS= read -r key; do
499
507
  [ -n "$key" ] || continue
500
508
  tmux_at send-keys -t "$sess" "$key" || return 1
501
- done < <(python3 -c 'import json,sys
502
- a=json.loads(sys.stdin.read() or "null")
503
- print("\n".join(a.get("keys") or []) if isinstance(a, dict) else "")' <<<"$json")
509
+ done <<<"$keys"
504
510
  return 0
505
511
  }
506
512
  while [ $SECONDS -lt "$room_ready_deadline" ]; do
@@ -592,6 +598,11 @@ if [ "$room_ready" != true ]; then
592
598
  exit 1
593
599
  fi
594
600
  echo "room ready: ${room}/${name}"
601
+
602
+ # ここから先のdialog処理・稼働状態・DM配送に必要なruntimeは、利用側へ順序を
603
+ # 委ねず正規着座入口が現行Peertable版へ収束させる。
604
+ PEERTABLE_CREDENTIAL_FILE="$credential_file" "$peertable_script_dir/ensure-project-runtime.sh" "$proj"
605
+
595
606
  if [ "$harness" = codex ]; then
596
607
  # member 登録後の最初の tool 呼び出しで MCP Allow が出る。出ている間だけ通し、沈黙5秒で抜ける。
597
608
  codex_post_ready_deadline=$((SECONDS + 90))
@@ -611,14 +622,16 @@ if [ "$harness" = codex ]; then
611
622
  done
612
623
  fi
613
624
  if [ "$brief_dispatched" = true ]; then
614
- brief_completed=true
615
625
  echo "briefed: ${sess}(Aiterm launch prompt)"
616
626
  fi
617
627
 
618
628
  if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
619
629
  if [ "$brief_in_composer" != true ]; then
620
- if node "$peertable_script_dir/aiterm-send.mjs" "$sess" "$brief_file" >/dev/null; then
621
- brief_completed=true
630
+ # TUIの入力可能判定とsubmitはAitermの公開 pty_send が所有する。ここで独自の
631
+ # prompt連続観測を重ねると、正常なTUI描画差だけでdispatch前に席を落とす。
632
+ if brief_send_receipt=$(node "$peertable_script_dir/aiterm-send.mjs" "$sess" "$brief_file"); then
633
+ brief_sent=true
634
+ brief_event_cursor=$(python3 -c 'import json,sys;print(json.loads(sys.argv[1]).get("event_cursor", ""))' "$brief_send_receipt")
622
635
  echo "briefed: $sess(Aiterm pty_send)"
623
636
  else
624
637
  echo "LAUNCH_BRIEF_SEND_FAILED: Aiterm pty_sendでbriefをdispatchできない" >&2
@@ -645,14 +658,15 @@ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
645
658
  sleep 1
646
659
  done
647
660
  if [ "$brief_ready" != true ]; then
648
- brief_not_ready=true
649
- echo "LAUNCH_BRIEF_NOT_READY: brief を受け付ける入力 prompt を観測できない(brief未投入・空席を保持。Aiterm手動dispatch対象)" >&2
661
+ tmux_at capture-pane -t "$sess" -p >"$proj/.team/${name}-pane-on-fail.txt" 2>/dev/null || true
662
+ echo "LAUNCH_BRIEF_NOT_READY: brief を受け付ける入力promptを観測できない" >&2
663
+ echo "pane saved: $proj/.team/${name}-pane-on-fail.txt" >&2
664
+ exit 1
650
665
  else
651
666
  # prompt の描画とキー入力受理の境界を分ける。Codex のTUIが入力欄を
652
667
  # mountした直後に paste と Enter を同一tickで受けると、Enterだけ落ちる。
653
668
  sleep 1
654
669
 
655
- brief_before=$(tmux_at capture-pane -S -1000 -t "$sess" -p 2>/dev/null || true)
656
670
  # submit_residue の席は本文が composer に残っている。貼り直すと二重になるので Enter だけ打つ。
657
671
  if [ "$brief_in_composer" != true ]; then
658
672
  brief_buffer="peertable-brief-${name}-$$"
@@ -672,28 +686,46 @@ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
672
686
  exit 1
673
687
  fi
674
688
 
675
- # 入力欄へ置けた事実だけでは着任成功としない。既存の席状態判定と同じ live marker が、
676
- # brief 投入後に画面へ現れたことを観測する。高速な fake/CLI の残像を拾わないよう、投入前の
677
- # 画面と異なることも同時に要求する。dispatch は Aiterm の手動送信と同じく、この1回だけ行う。
678
- brief_deadline=$((SECONDS + 30))
679
- brief_turn_started=false
680
- while [ $SECONDS -lt "$brief_deadline" ]; do
681
- brief_screen=$(tmux_at capture-pane -S -1000 -t "$sess" -p 2>/dev/null || true)
682
- case "$brief_screen" in
683
- *"esc to interrupt"*)
684
- if [ "$brief_screen" != "$brief_before" ]; then brief_turn_started=true; break; fi
685
- ;;
686
- esac
689
+ brief_sent=true
690
+ echo "briefed: $sess"
691
+ fi
692
+ fi
693
+ fi
694
+
695
+ # 「promptを渡した」ではなく、全harness共通で実ターンが始まったことを成功条件にする。
696
+ # 1秒未満で完了するturnはAitermの完了正本で拾い、通常turnは現在paneのbusyランプで拾う。
697
+ if [ -n "$brief" ] && [ "$brief_sent" = true ]; then
698
+ command -v aiterm-wait >/dev/null 2>&1 || { echo "LAUNCH_AITERM_WAIT_MISSING: 実ターン開始を確認できない" >&2; exit 1; }
699
+ brief_active_deadline=$((SECONDS + 60))
700
+ brief_turn_observed=false
701
+ while [ $SECONDS -lt "$brief_active_deadline" ]; do
702
+ brief_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
703
+ if [ "$harness" = codex ] && pass_codex_pane "$brief_screen"; then
704
+ sleep 1
705
+ continue
706
+ fi
707
+ brief_pane_status=$(printf '%s' "$brief_screen" | node "$peertable_script_dir/agent-pane-status.mjs")
708
+ if [ "$brief_pane_status" = busy ]; then
709
+ brief_turn_observed=true
710
+ break
711
+ fi
712
+ if [ -n "$brief_event_cursor" ]; then
713
+ wait_receipt=$(aiterm-wait --session "$sess" --cursor "$brief_event_cursor" --timeout 0 2>/dev/null || true)
714
+ if printf '%s' "$wait_receipt" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{try{process.exit(JSON.parse(s).outcome==="done"?0:1)}catch{process.exit(1)}})'; then
715
+ brief_turn_observed=true
716
+ break
717
+ fi
718
+ fi
687
719
  sleep 1
688
720
  done
689
- if [ "$brief_turn_started" != true ]; then
690
- echo "LAUNCH_BRIEF_TURN_NOT_STARTED: brief 投入後の turn 開始を観測できない(席は着席済み)" >&2
721
+ if [ "$brief_turn_observed" != true ]; then
722
+ tmux_at capture-pane -t "$sess" -p >"$proj/.team/${name}-pane-on-fail.txt" 2>/dev/null || true
723
+ echo "LAUNCH_BRIEF_TURN_NOT_STARTED: Aiterm dispatch後に${harness}の実ターン開始も完了も観測できない" >&2
724
+ echo "pane saved: $proj/.team/${name}-pane-on-fail.txt" >&2
691
725
  exit 1
692
726
  fi
693
727
  brief_completed=true
694
- echo "briefed: $sess"
695
- fi
696
- fi
728
+ echo "active: $sess(実ターン開始を観測)"
697
729
  fi
698
730
 
699
731
  # 席の本人性(pid / 起動時刻 / argv digest)を room 台帳の member 行へ登録する。
@@ -762,28 +794,5 @@ else
762
794
  echo "SEAT_LEDGER_INCOMPLETE: 台帳の member 行に素性または本人性が欠けている(席は着席済み。doctor で確認)" >&2
763
795
  fi
764
796
 
765
- PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" alarm || echo "alarm-bridge の起動確認に失敗した(席は着席済み)" >&2
766
- if PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" seat-status; then
767
- echo "seat-status-bridge: 起動確認済み"
768
- else
769
- echo "seat-status-bridge の起動確認に失敗した(席は着席済み)" >&2
770
- fi
771
-
772
- # Claude 席の新着は room client の notifications/claude/channel。TUI 配達は channels を持たない
773
- # Codex / Grok 席だけ。Claude に立てると channel と二重配達になる。
774
- if [ "$harness" = codex ] || [ "$harness" = grok ]; then
775
- if PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" wakeup; then
776
- echo "wakeup-bridge: TUI配達 起動確認済み"
777
- else
778
- echo "SEAT_WAKEUP_BRIDGE_NOT_READY: Codex/Grok席の TUI 配達を準備できない" >&2
779
- exit 1
780
- fi
781
- fi
782
-
783
797
  if [ -z "$brief" ]; then brief_completed=true; fi
784
798
  credential_persist=true
785
- if [ "$brief_not_ready" = true ]; then
786
- # 空席の後段セットアップ(identity / metadata / bridge)まで済ませたうえで、
787
- # 呼び出し側にはready未確認を非0で返す。席はAiterm手動dispatchへ引き継ぐ。
788
- exit 1
789
- fi
File without changes
File without changes
File without changes
@@ -330,9 +330,7 @@ async function catchUp() {
330
330
  // 長寿命なのはbackground taskのloopだけで、端末sessionやNode processは常駐させない。
331
331
  if (mode === '--poll') {
332
332
  await catchUp()
333
- process.exit(0)
334
- }
335
-
333
+ } else {
336
334
  const nextWindowMs = Number(process.env.PEERTABLE_PARENT_WATCH_WINDOW_MS ?? 55_000)
337
335
  if (mode === '--next' && (!Number.isFinite(nextWindowMs) || nextWindowMs < 100)) {
338
336
  console.error('PARENT_WATCH_WINDOW_INVALID')
@@ -411,3 +409,4 @@ for (;;) {
411
409
  await sleep(Math.min(2000, Math.max(1, deadline - Date.now())))
412
410
  }
413
411
  }
412
+ }
@@ -28,9 +28,14 @@ export function buildWindowsBridgeLaunch({
28
28
  const value = env[name]
29
29
  if (value) statements.push(`$env:${name} = ${quotePowerShell(value)}`)
30
30
  }
31
+ statements.push('$utf8 = [System.Text.UTF8Encoding]::new($false)')
32
+ statements.push('[Console]::InputEncoding = $utf8')
33
+ statements.push('[Console]::OutputEncoding = $utf8')
34
+ statements.push('$OutputEncoding = $utf8')
31
35
  const command = [node, script, project, ...args].map(quotePowerShell).join(' ')
32
- statements.push(`& ${command} *>> ${quotePowerShell(log)}`)
33
- statements.push('exit $LASTEXITCODE')
36
+ statements.push(`& ${command} 2>&1 | Out-File -FilePath ${quotePowerShell(log)} -Append -Encoding utf8`)
37
+ statements.push('$bridgeExit = $LASTEXITCODE')
38
+ statements.push('exit $bridgeExit')
34
39
  const encoded = Buffer.from(statements.join('; '), 'utf16le').toString('base64')
35
40
  const argv = ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded]
36
41
  return {
File without changes
@@ -150,15 +150,8 @@ done < <(node -e '
150
150
  ' "$seats_file")
151
151
  rm -f "$seats_file"
152
152
 
153
- # Phase C: bridge の再起動
154
- for kind in alarm seat-status wakeup; do
155
- if "$script_dir/ensure-bridge.sh" "$proj" "$kind"; then
156
- echo "[実施] ${kind}-bridge: ready"
157
- else
158
- echo "[未実施] ${kind}-bridge: 起動を確認できなかった(ログ $proj/.team/${kind}-bridge.log)" >&2
159
- exit 1
160
- fi
161
- done
153
+ # Phase C: runtime を一回の入口で現行版へ収束
154
+ "$script_dir/ensure-project-runtime.sh" "$proj"
162
155
 
163
156
  # Phase D: fresh heartbeat の読み戻しと probe DM の配送 receipt 確認
164
157
  RESUME_PROJ="$proj" RESUME_PROBE="$probe" RESUME_SCRIPT_DIR="$script_dir" node --input-type=module <<'NODE'
@@ -0,0 +1,158 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFileSync } from 'node:fs'
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { fileURLToPath } from 'node:url'
8
+ import test from 'node:test'
9
+
10
+ import { keysForCodexPane } from './codex-dialog.mjs'
11
+ import { buildWindowsBridgeLaunch } from './platform/windows/build-bridge-command.mjs'
12
+ import { paneStatusTail } from './seat-usage.mjs'
13
+ import { classifyGrokPaneTail } from './vendors/grok/pane-status.mjs'
14
+ import { latticeTaskAvailable } from './alarm-condition.mjs'
15
+ import { boundedRecent, boundedUnread } from '../../room/message-bounds.mjs'
16
+
17
+ test('Grokの通信失敗はWaiting表示が残ってもbusyにしない', () => {
18
+ const tail = [
19
+ 'Connection failed — reqwest error stream: error sending request.',
20
+ 'Check your network and try again.',
21
+ 'Waiting for response… 29s [stop]',
22
+ ].join('\n')
23
+ assert.equal(classifyGrokPaneTail(tail), 'blocked')
24
+ })
25
+
26
+ test('通信失敗の無いGrok応答待ちはbusy', () => {
27
+ assert.equal(classifyGrokPaneTail('Waiting for response… 12s [stop]'), 'busy')
28
+ })
29
+
30
+ test('状態判定窓は折返しで14行より上へ出た通信失敗を含む', () => {
31
+ const pane = ['Connection failed — reqwest error stream', ...Array(20).fill('wrapped line'), 'Waiting for response…'].join('\n')
32
+ assert.ok(paneStatusTail(pane).includes('Connection failed'))
33
+ })
34
+
35
+ test('Codexのroom許可とcommand許可は固定選択肢だけを通す', () => {
36
+ assert.deepEqual(keysForCodexPane([
37
+ 'Allow the room MCP server to run tool "members"?',
38
+ '3. Always allow',
39
+ ].join('\n')), { kind: 'mcp-allow', keys: ['Down', 'Down', 'Enter'] })
40
+ assert.deepEqual(keysForCodexPane([
41
+ 'Would you like to run the following command?',
42
+ "2. Yes, and don't ask again for commands that start with Get-Content",
43
+ ].join('\n')), { kind: 'command-approval', keys: ['Down', 'Enter'] })
44
+ })
45
+
46
+ test('Codexの現在busy/idle composerはscrollbackの古い許可文より優先する', () => {
47
+ const stale = [
48
+ 'Would you like to run the following command?',
49
+ "2. Yes, and don't ask again for commands that start with rg",
50
+ ]
51
+ assert.equal(keysForCodexPane([...stale, '• Working (8s • esc to interrupt)', '› Ask Codex to do anything', 'gpt-5.6-terra high · ~/work'].join('\n')), null)
52
+ assert.equal(keysForCodexPane([...stale, '› Ask Codex to do anything', 'gpt-5.6-terra high · ~/work'].join('\n')), null)
53
+ })
54
+
55
+ test('着座はAiterm dispatch前に独自prompt連続判定を重ねない', () => {
56
+ const source = readFileSync(new URL('./launch-seat.sh', import.meta.url), 'utf8')
57
+ const activeBranch = source.slice(
58
+ source.indexOf('if [ "$brief_in_composer" != true ]; then'),
59
+ source.indexOf(' else\n # Codex はヘッダを描いた後も MCP 初期化を続ける。'),
60
+ )
61
+ assert.ok(activeBranch.includes('aiterm-send.mjs'))
62
+ assert.ok(!activeBranch.includes('brief_ready_streak'))
63
+ assert.ok(source.includes('brief_turn_observed'))
64
+ assert.ok(source.includes('agent-pane-status.mjs'))
65
+ assert.ok(source.includes('aiterm-wait --session'))
66
+ })
67
+
68
+ test('wakeup bridgeはpending DMが無くてもCodex既知dialogを巡回する', () => {
69
+ const source = readFileSync(new URL('./wakeup-bridge.mjs', import.meta.url), 'utf8')
70
+ assert.ok(source.includes('dialogSweepRunning'))
71
+ assert.ok(source.includes('await passKnownCodexDialog(member)'))
72
+ })
73
+
74
+ test('円卓runtimeは3bridgeを固定順で一括収束する', () => {
75
+ const source = readFileSync(new URL('./ensure-project-runtime.sh', import.meta.url), 'utf8')
76
+ assert.ok(source.includes('for kind in alarm seat-status wakeup'))
77
+ })
78
+
79
+ test('bridge更新は版数だけでなくruntime source digestへ束縛する', () => {
80
+ const digest = spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-digest.mjs', import.meta.url))], { encoding: 'utf8' })
81
+ assert.equal(digest.status, 0, digest.stderr)
82
+ assert.match(digest.stdout, /^[0-9a-f]{64}$/u)
83
+ const source = readFileSync(new URL('./ensure-bridge.sh', import.meta.url), 'utf8')
84
+ assert.ok(source.includes('peertable_runtime_digest'))
85
+ })
86
+
87
+ test('Windows bridgeはUTF-8を明示してログへ書く', () => {
88
+ const launch = buildWindowsBridgeLaunch({ script: 'bridge.mjs', project: 'C:\\work', log: 'C:\\work\\bridge.log' })
89
+ const encodedAt = launch.argv.indexOf('-EncodedCommand')
90
+ const decoded = Buffer.from(launch.argv[encodedAt + 1], 'base64').toString('utf16le')
91
+ assert.ok(decoded.includes('[Console]::OutputEncoding = $utf8'))
92
+ assert.ok(decoded.includes('Out-File'))
93
+ assert.ok(decoded.includes('-Encoding utf8'))
94
+ })
95
+
96
+ test('alarm writerは日本語noteをUTF-8 stdinから保存する', () => {
97
+ const dir = mkdtempSync(join(tmpdir(), 'peertable-alarm-'))
98
+ const out = join(dir, 'alarm.json')
99
+ try {
100
+ const input = ['script', 'yuna', 't04-integration が ready になったら工程を確認する', 'exit 0'].join('\0')
101
+ const result = spawnSync(process.execPath, [fileURLToPath(new URL('./alarm-write.mjs', import.meta.url)), out], {
102
+ input: Buffer.from(input, 'utf8'),
103
+ encoding: 'utf8',
104
+ })
105
+ assert.equal(result.status, 0, result.stderr)
106
+ const saved = JSON.parse(readFileSync(out, 'utf8'))
107
+ assert.equal(saved.note, 't04-integration が ready になったら工程を確認する')
108
+ } finally {
109
+ rmSync(dir, { recursive: true, force: true })
110
+ }
111
+ })
112
+
113
+ test('Lattice task alarmはready/activeだけで成立し未出現taskでは発火しない', () => {
114
+ const status = {
115
+ next_ready: [{ plan_key: 'p', task_id: 't04' }],
116
+ active_set: [{ plan_key: 'p', task_id: 't03' }],
117
+ }
118
+ assert.equal(latticeTaskAvailable(status, 't04', 'p'), true)
119
+ assert.equal(latticeTaskAvailable(status, 't03', 'p'), true)
120
+ assert.equal(latticeTaskAvailable(status, 't05', 'p'), false)
121
+ assert.equal(latticeTaskAvailable(status, 't04', 'other'), false)
122
+ })
123
+
124
+ test('Grok席configはClaude/Cursor互換を止めroom以外のproject MCPを無効化する', () => {
125
+ const dir = mkdtempSync(join(tmpdir(), 'peertable-grok-seat-'))
126
+ const output = join(dir, 'seat', 'config.toml')
127
+ try {
128
+ writeFileSync(join(dir, '.mcp.json'), JSON.stringify({ mcpServers: {
129
+ booth: { command: 'node' }, room: { command: 'node' }, extra: { command: 'node' },
130
+ } }))
131
+ const result = spawnSync(process.execPath, [fileURLToPath(new URL('./grok-seat-config.mjs', import.meta.url)), dir, output], { encoding: 'utf8' })
132
+ assert.equal(result.status, 0, result.stderr)
133
+ const config = readFileSync(output, 'utf8')
134
+ assert.match(config, /\[compat\.claude\][\s\S]*mcps = false/u)
135
+ assert.match(config, /\[compat\.cursor\][\s\S]*hooks = false/u)
136
+ assert.match(config, /disabled_mcp_servers = \["booth","extra"\]/u)
137
+ assert.doesNotMatch(config, /"room"/u)
138
+ } finally {
139
+ rmSync(dir, { recursive: true, force: true })
140
+ }
141
+ })
142
+
143
+ test('room logは20件・UTF-8 12KB以内で古い分を明示省略する', () => {
144
+ const messages = Array.from({ length: 30 }, (_, index) => ({ seq: index + 1, body: `本文${index + 1}`.repeat(300) }))
145
+ const result = boundedRecent(messages, message => `[${message.seq}] ${message.body}`, 50)
146
+ assert.ok(Buffer.byteLength(result.text, 'utf8') <= 12_100)
147
+ assert.ok(result.requested === 20)
148
+ assert.match(result.text, /古い \d+ 件を出力上限のため省略/u)
149
+ assert.match(result.text, /\[30\]/u)
150
+ })
151
+
152
+ test('room未読は返した位置までだけ進み残りを次回へ保つ', () => {
153
+ const messages = Array.from({ length: 6 }, (_, index) => ({ seq: index + 1, body: `未読${index + 1}`.repeat(500) }))
154
+ const first = boundedUnread(messages, () => true, message => `[${message.seq}] ${message.body}`, 4000)
155
+ assert.ok(first.omitted > 0)
156
+ assert.ok(first.consumedSeq < 6)
157
+ assert.match(first.text, /read_unreadを再実行/u)
158
+ })
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto'
3
+ import { readdirSync, readFileSync } from 'node:fs'
4
+ import { dirname, join, relative } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const root = dirname(fileURLToPath(import.meta.url))
8
+ const files = []
9
+ function walk(dir) {
10
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
11
+ const path = join(dir, entry.name)
12
+ if (entry.isDirectory()) walk(path)
13
+ else if ((entry.name.endsWith('.mjs') || entry.name.endsWith('.sh')) && !entry.name.endsWith('.test.mjs')) files.push(path)
14
+ }
15
+ }
16
+ walk(root)
17
+ files.sort((a, b) => relative(root, a).localeCompare(relative(root, b), 'en'))
18
+ const hash = createHash('sha256')
19
+ for (const file of files) {
20
+ hash.update(relative(root, file).replaceAll('\\', '/'))
21
+ hash.update('\0')
22
+ hash.update(readFileSync(file))
23
+ hash.update('\0')
24
+ }
25
+ process.stdout.write(hash.digest('hex'))
@@ -27,7 +27,7 @@ import { execFileSync } from 'node:child_process'
27
27
  import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs'
28
28
  import { join } from 'node:path'
29
29
 
30
- import { STOP_DECLARATION, attributeJobSession, combineSeatLamp, isPaneHarnessMissing, hasActiveDescendant, subtreeCpuSeconds, patrolTargets, classifyPaneTail, decideBridgeContinuation, deriveMissingSession, isPaneProcessStopped, parsePaneTokenHint, resolveLatticeExecutable, resolvePostToken, resolveSeatObservation, resolveTmuxSocket, supportsMemberObservation, tmuxArgv, tmuxPanePid } from './seat-usage.mjs'
30
+ import { STOP_DECLARATION, attributeJobSession, combineSeatLamp, isPaneHarnessMissing, hasActiveDescendant, subtreeCpuSeconds, patrolTargets, classifyPaneTail, decideBridgeContinuation, deriveMissingSession, isPaneProcessStopped, paneStatusTail, parsePaneTokenHint, resolveLatticeExecutable, resolvePostToken, resolveSeatObservation, resolveTmuxSocket, supportsMemberObservation, tmuxArgv, tmuxPanePid } from './seat-usage.mjs'
31
31
  import { updateBridgeProgress } from './bridge-record-live.mjs'
32
32
 
33
33
  const args = process.argv.slice(2)
@@ -111,7 +111,7 @@ function readSeat(member, previous, observedAt) {
111
111
  }
112
112
  const pane = tmux(target.socket, 'capture-pane', '-t', target.target, '-p')
113
113
  if (pane === null) return { status: 'dead', busySince: null, paneTokenHint: null }
114
- const tail = pane.split('\n').slice(-14).join('\n')
114
+ const tail = paneStatusTail(pane)
115
115
  const tentativeStatus = classifyPaneTail(tail)
116
116
  // pane 自体は生きたまま中の CLI プロセスだけが停止する局面(Lattice pull run の accept hold 等)を
117
117
  // 拾う。画面の残像だけでは idle に誤判定するため、idle と読めた時だけ実プロセスの stat を見る。
@@ -202,6 +202,14 @@ export const BLOCKED_MARKERS = [
202
202
 
203
203
  export { isGrokPrivacyBanner }
204
204
 
205
+ // 承認画面や接続失敗は折返し・キュー表示で14行より上へ押し出される。現在のturnを
206
+ // 覆いながら、古いscrollback全体を状態に混ぜない固定窓として32行を読む。
207
+ export const PANE_STATUS_TAIL_LINES = 32
208
+
209
+ export function paneStatusTail(pane) {
210
+ return String(pane ?? '').split('\n').slice(-PANE_STATUS_TAIL_LINES).join('\n')
211
+ }
212
+
205
213
  /**
206
214
  * pane 末尾の生文字列から画面状態を判定する。判定順は busy → blocked → idle
207
215
  * (承認プロンプト表示中は `esc to interrupt` が消えるので busy を先に見る)。
File without changes
@@ -121,6 +121,8 @@ else
121
121
  fi
122
122
  # 目覚まし係の登録口は全モード共通で配る(member.md の待機作法が参照する)
123
123
  cp "$repo/skill/scripts/alarm-set.sh" "$tdir/scripts/alarm-set.sh" && chmod +x "$tdir/scripts/alarm-set.sh"
124
+ cp "$repo/skill/scripts/alarm-write.mjs" "$tdir/scripts/alarm-write.mjs" && chmod +x "$tdir/scripts/alarm-write.mjs"
125
+ cp "$repo/skill/scripts/alarm-condition.mjs" "$tdir/scripts/alarm-condition.mjs" && chmod +x "$tdir/scripts/alarm-condition.mjs"
124
126
  # member.md が attach 手順で参照する。配り漏れると席が attach できず停止する(実被弾 2026-08-22)
125
127
  cp "$repo/skill/scripts/pull-attach-input.mjs" "$tdir/scripts/pull-attach-input.mjs" && chmod +x "$tdir/scripts/pull-attach-input.mjs"
126
128
  # room MCP 定義は project root の .mcp.json が正(channels は --mcp-config を解決しない。決定44)
@@ -209,11 +211,6 @@ fi
209
211
  # 4時間 HTTP 403 を撃ち続け、参加者一覧には点が1つも出なかった=起こしていないのと見分けがつかない)。
210
212
  # AI は使わず席へも1バイト送らないので卓の作業を邪魔しない。**書けない時は常駐せずに死ぬ**ので、
211
213
  # ここで壊れた常駐が黙って残ることは無い。setup-state.json を読むので、必ずその後で起こす。
212
- "$repo/skill/scripts/ensure-bridge.sh" "$proj" alarm || echo "alarm-bridge の起動確認に失敗した(席は使える・目覚まし係だけ後で ensure-bridge.sh で立てる)" >&2
213
- if "$repo/skill/scripts/ensure-bridge.sh" "$proj" seat-status; then
214
- echo "seat-status-bridge: ready_at を確認した(ログ $tdir/seat-status-bridge.log・停止は teardown が行う)"
215
- else
216
- echo "seat-status-bridge: 起動を確認できなかった(ログ $tdir/seat-status-bridge.log)" >&2
217
- fi
214
+ "$repo/skill/scripts/ensure-project-runtime.sh" "$proj"
218
215
 
219
216
  echo "scaffold done: $tdir"
File without changes
@@ -1,22 +1,22 @@
1
- # sourced by launch-seat / leave-seat / teardown / ensure-bridge / change-seat
2
- # POSIX は tmux -S <sock>。Windows psmux は -L <aiterm-ns>(-S は既定 namespace へ落ちる)。
3
- _peertable_tmux_scripts=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
4
- tmux_at() {
5
- local prefix
6
- prefix=$(node "$_peertable_tmux_scripts/tmux-socket.mjs" --prefix) || return 1
7
- # shellcheck disable=SC2206
8
- local -a conn=($prefix)
9
- # tmux server は最初に立てた process の locale を一生使い、C/POSIX/未設定だと UTF-8 の
10
- # send-keys/paste を破壊する(実被弾 2026-08-29: launchd の bridge 巡回が LANG 無しで
11
- # server を立て、以後の日本語込み席起動コマンドが全て化けて Codex 席が着席不能になった)。
12
- # launchd/cron 由来の呼び出しでも server が UTF-8 で立つよう、壊れた既定の時だけ注入する。
13
- # 利用者が C/POSIX 以外を明示設定している場合はその選択を尊重して触らない(aiterm と同じ判定)。
14
- local effective="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
15
- if [ -z "$effective" ] || [[ "$effective" =~ ^(C|POSIX)$ ]]; then
16
- local ctype="C.UTF-8"
17
- [ "$(uname)" = "Darwin" ] && ctype="UTF-8"
18
- env -u LC_ALL LC_CTYPE="$ctype" tmux "${conn[@]}" "$@"
19
- else
20
- command tmux "${conn[@]}" "$@"
21
- fi
22
- }
1
+ # sourced by launch-seat / leave-seat / teardown / ensure-bridge / change-seat
2
+ # POSIX は tmux -S <sock>。Windows psmux は -L <aiterm-ns>(-S は既定 namespace へ落ちる)。
3
+ _peertable_tmux_scripts=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
4
+ tmux_at() {
5
+ local prefix
6
+ prefix=$(node "$_peertable_tmux_scripts/tmux-socket.mjs" --prefix) || return 1
7
+ # shellcheck disable=SC2206
8
+ local -a conn=($prefix)
9
+ # tmux server は最初に立てた process の locale を一生使い、C/POSIX/未設定だと UTF-8 の
10
+ # send-keys/paste を破壊する(実被弾 2026-08-29: launchd の bridge 巡回が LANG 無しで
11
+ # server を立て、以後の日本語込み席起動コマンドが全て化けて Codex 席が着席不能になった)。
12
+ # launchd/cron 由来の呼び出しでも server が UTF-8 で立つよう、壊れた既定の時だけ注入する。
13
+ # 利用者が C/POSIX 以外を明示設定している場合はその選択を尊重して触らない(aiterm と同じ判定)。
14
+ local effective="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
15
+ if [ -z "$effective" ] || [[ "$effective" =~ ^(C|POSIX)$ ]]; then
16
+ local ctype="C.UTF-8"
17
+ [ "$(uname)" = "Darwin" ] && ctype="UTF-8"
18
+ env -u LC_ALL LC_CTYPE="$ctype" tmux "${conn[@]}" "$@"
19
+ else
20
+ command tmux "${conn[@]}" "$@"
21
+ fi
22
+ }
@@ -201,6 +201,10 @@ function main() {
201
201
  const common = [
202
202
  ['.team/CLAUDE.md', 'skill/templates/charter.md', 0o644],
203
203
  ['.team/roles/parent.md', 'skill/templates/parent.md', 0o644],
204
+ ['.team/scripts/alarm-set.sh', 'skill/scripts/alarm-set.sh', 0o755],
205
+ ['.team/scripts/alarm-write.mjs', 'skill/scripts/alarm-write.mjs', 0o755],
206
+ ['.team/scripts/alarm-condition.mjs', 'skill/scripts/alarm-condition.mjs', 0o755],
207
+ ['.team/scripts/pull-attach-input.mjs', 'skill/scripts/pull-attach-input.mjs', 0o755],
204
208
  ]
205
209
  const modeSpecific = state.mode === 'lattice'
206
210
  ? [
@@ -4,6 +4,13 @@ export function classifyGrokPaneTail(tail) {
4
4
 
5
5
  if (isGrokPrivacyBanner(tail)) return 'blocked'
6
6
 
7
+ // Grok Build は通信失敗後も `Waiting for response` と `[stop]` を画面へ残し、
8
+ // 再試行中のように見せる。この状態を busy にすると「モデルが仕事を進めている」
9
+ // アクティブランプが点き続けるため、既知の接続失敗を先に blocked と判定する。
10
+ if (tail.includes('Connection failed — reqwest error')
11
+ || tail.includes('Connection failed - reqwest error')
12
+ || tail.includes('Check your network and try again.')) return 'blocked'
13
+
7
14
  // 完了後も `stop [hooks: 1/3]` はステータス行へ残る。入力欄が戻った
8
15
  // `Worked for ...` 行を先にidle扱いし、配達を永久保留しない。
9
16
  if (/Worked for\s+\d+(?:m\d+)?s[\s\S]*?stop\s+\[hooks:\s*\d+\/\d+\]/u.test(tail)) return 'idle'