peertable 0.8.23 → 0.8.26

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.23",
3
+ "version": "0.8.26",
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",
@@ -35,6 +35,7 @@
35
35
  "files": [
36
36
  "room/server.mjs",
37
37
  "room/client.mjs",
38
+ "room/message-bounds.mjs",
38
39
  "room/Dockerfile",
39
40
  "skill/",
40
41
  "!skill/**/__pycache__/**",
package/room/client.mjs CHANGED
@@ -10,10 +10,11 @@ import { dirname, join } from 'node:path'
10
10
  import { fileURLToPath } from 'node:url'
11
11
  import { isIdleSelfWake } from '../skill/scripts/wakeup-delivery.mjs'
12
12
  import { findModelsDoc, resolveSeatIdentity } from '../skill/scripts/resolve-seat-placement.mjs'
13
+ import { boundedRecent, boundedUnread } from './message-bounds.mjs'
13
14
 
14
15
  // client.mjs 側のハードコード版数。package.json の version と一致していることを
15
16
  // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
16
- const MCP_VERSION = '0.8.23'
17
+ const MCP_VERSION = '0.8.26'
17
18
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
18
19
 
19
20
  const USAGE = `usage:
@@ -108,7 +109,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
108
109
  },
109
110
  },
110
111
  { name: 'read_unread', description: 'room全体宛と自分宛の未読メッセージを読む。読んだ位置は記憶される', inputSchema: { type: 'object', properties: {} } },
111
- { name: 'read_log', description: 'room ログの直近 count 件を読む(既定 50。全宛先を含む)', inputSchema: { type: 'object', properties: { count: { type: 'number' } } } },
112
+ { name: 'read_log', description: 'roomログの直近count件を読む(既定20・最大20・UTF-8 12KB上限。全宛先を含む)', inputSchema: { type: 'object', properties: { count: { type: 'number' } } } },
112
113
  { name: 'members', description: 'room に居るメンバーの一覧(名前・役割・設定・使命・実効稼働状態)と bridge 健全性。状態が unknown の席と bridge 障害はここで分かる', inputSchema: { type: 'object', properties: {} } },
113
114
  { name: 'delivery_status', description: '発言 seq の宛先別配達状態を照会する。delivered / pending / seat_unavailable / bridge_unavailable / failed。room_saved は配達成功ではない', inputSchema: { type: 'object', properties: { seq: { type: 'number' } }, required: ['seq'] } },
114
115
  ],
@@ -183,14 +184,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
183
184
  }
184
185
  case 'read_unread': {
185
186
  const { messages } = await (await fetch(api(`messages?since=${cursor}`))).json()
186
- if (messages.length) { cursor = messages[messages.length - 1].seq; ackSeq = cursor }
187
- const mine = messages.filter(relevant)
187
+ const unread = boundedUnread(messages, relevant, fmt)
188
+ if (unread.consumedSeq !== null) { cursor = unread.consumedSeq; ackSeq = cursor }
188
189
  const roster = await rosterText()
189
- return text(mine.length ? `${roster}\n${mine.map(fmt).join('\n')}` : `${roster}\n未読なし`)
190
+ return text(unread.text ? `${roster}\n${unread.text}` : `${roster}\n未読なし`)
190
191
  }
191
192
  case 'read_log': {
192
193
  const { messages } = await (await fetch(api('messages'))).json()
193
- return text(messages.slice(-(args.count ?? 50)).map(fmt).join('\n') || '(ログなし)')
194
+ return text(boundedRecent(messages, fmt, args.count).text || '(ログなし)')
194
195
  }
195
196
  case 'members': {
196
197
  const { members, bridges } = await (await fetch(api('members'))).json()
@@ -406,6 +407,16 @@ async function runDiagnostics(asJson) {
406
407
  'scripts/wakeup-bridge.mjs',
407
408
  'scripts/wakeup-delivery.mjs',
408
409
  'scripts/seat-status-bridge.mjs',
410
+ 'scripts/ensure-bridge.sh',
411
+ 'scripts/ensure-project-runtime.sh',
412
+ 'scripts/runtime-digest.mjs',
413
+ 'scripts/alarm-bridge.mjs',
414
+ 'scripts/alarm-set.sh',
415
+ 'scripts/alarm-write.mjs',
416
+ 'scripts/alarm-condition.mjs',
417
+ 'scripts/agent-pane-status.mjs',
418
+ 'scripts/codex-dialog.mjs',
419
+ 'scripts/grok-seat-config.mjs',
409
420
  // 円卓開始ゲートと既存 room の正規 resume 入口(決定104・105)。欠けると親の依頼確定と再稼働が手作業へ戻る
410
421
  'scripts/kickoff-gate.mjs',
411
422
  'scripts/resume.sh',
@@ -0,0 +1,55 @@
1
+ const bytes = text => Buffer.byteLength(text, 'utf8')
2
+
3
+ export function truncateUtf8(text, maxBytes) {
4
+ if (bytes(text) <= maxBytes) return text
5
+ const suffix = '…'
6
+ let used = bytes(suffix)
7
+ let out = ''
8
+ for (const cp of text) {
9
+ const size = bytes(cp)
10
+ if (used + size > maxBytes) break
11
+ out += cp
12
+ used += size
13
+ }
14
+ return `${out}${suffix}`
15
+ }
16
+
17
+ export function boundedUnread(messages, relevant, formatter, maxBytes = 12_000) {
18
+ const relevantMessages = messages.filter(relevant)
19
+ const rendered = []
20
+ let used = 0
21
+ for (const message of relevantMessages) {
22
+ const separator = rendered.length ? 1 : 0
23
+ let item = formatter(message)
24
+ if (rendered.length === 0 && bytes(item) > maxBytes) item = truncateUtf8(item, maxBytes)
25
+ const size = bytes(item) + separator
26
+ if (used + size > maxBytes) break
27
+ rendered.push({ message, text: item })
28
+ used += size
29
+ }
30
+ const omitted = relevantMessages.length - rendered.length
31
+ const lastIncludedSeq = rendered.at(-1)?.message?.seq ?? null
32
+ const consumedSeq = omitted === 0
33
+ ? (messages.at(-1)?.seq ?? null)
34
+ : lastIncludedSeq
35
+ const tail = omitted > 0 ? `\n[未読残り ${omitted} 件。read_unreadを再実行]` : ''
36
+ return { text: `${rendered.map(item => item.text).join('\n')}${tail}`, omitted, consumedSeq }
37
+ }
38
+
39
+ export function boundedRecent(messages, formatter, count = 20, maxBytes = 12_000) {
40
+ const requested = Math.max(1, Math.min(20, Number.isFinite(Number(count)) ? Math.trunc(Number(count)) : 20))
41
+ const selected = messages.slice(-requested)
42
+ const rendered = []
43
+ let used = 0
44
+ for (let index = selected.length - 1; index >= 0; index--) {
45
+ let item = formatter(selected[index])
46
+ if (rendered.length === 0 && bytes(item) > maxBytes) item = truncateUtf8(item, maxBytes)
47
+ const size = bytes(item) + (rendered.length ? 1 : 0)
48
+ if (used + size > maxBytes) break
49
+ rendered.unshift(item)
50
+ used += size
51
+ }
52
+ const omitted = selected.length - rendered.length
53
+ const prefix = omitted > 0 ? `[古い ${omitted} 件を出力上限のため省略]\n` : ''
54
+ return { text: `${prefix}${rendered.join('\n')}`, omitted, requested }
55
+ }
package/skill/SKILL.md CHANGED
@@ -55,24 +55,24 @@ script は内部で Aiterm の公開 `claude_agent` / `codex_agent` / `grok_agen
55
55
  - **運用中のworker席数の標準は「ready+activeな実装ToDo数」(決定68)**: 監査専任席はworker数、reclaim、scale-down候補へ含めず、最終試験結果を待って監査を担う。claimできるToDoが無いworker席は仕事を発明せず、最終手段として親だけへ待機DMする
56
56
  - **モードの選び分け**: タスク間に依存があり並列境界の機械保証が要るなら Lattice 併用。依存の無い小規模作業で、対象プロジェクトに Lattice を持ち込みたくないなら単独。単独で失うのは task 間スケジューリングの機械保証だけで、円卓の核(room・憲章・宣言による協力)は変わらない(決定47)
57
57
  2. **命名**: メンバーに日本のアニメキャラ風の可愛い名前を都度決める(固定リストなし)。識別子(tmux セッション名・room 登録名・Lattice actor)はローマ字、表示・自己紹介は日本語(決定35)
58
- 3. **scaffold**: `PEERTABLE_PUBLIC_URL=<公開URL基底> scripts/setup.sh <project> <room> <server_url> <plan_key|-> <peertable_repo> [tasks_file]` を実行する。`.team/`(憲章・roles/member.md ほか)と project root の `.mcp.json`(room MCP 定義。決定44)を templates から生成・置換し、`.git/info/exclude` へ `.team/` と `/.mcp.json` を追記し、作成記録を `.team/setup-state.json`(`mode` を含む)に残す
58
+ 3. **scaffold**: `PEERTABLE_PUBLIC_URL=<公開URL基底> scripts/setup.sh <project> <room> <server_url> <plan_key|-> <peertable_repo> [tasks_file]` を1回実行する。`.team/`(憲章・roles/member.md ほか)と project root の `.mcp.json`(room MCP 定義。決定44)を templates から生成・置換し、`.git/info/exclude` へ `.team/` と `/.mcp.json` を追記し、作成記録を `.team/setup-state.json`(`mode` を含む)に残す。alarm/seat-status/wakeup の起動・版数更新・順序制御は同じ入口が行い、AIが個別bridgeを起動しない
59
59
  - **Lattice 併用**: `plan_key` に plan key を渡す。`.team/scripts/done.sh` も配られる。加えて `scripts/external-pane.mjs` が対象 project の `.lattice/project.json` へ `external_pane`(工程表の右ペインに円卓を差す口。決定53)を書く
60
60
  - **phase で卓の範囲を絞る**(決定59): 複数 phase の plan へ相乗りする時は `--phase <id>`(複数可・位置引数の後ろ)を渡す。`setup-state.json` へ記録され、席の役割文書へ「claim 範囲はこの phase の task だけ」が焼き込まれる。指定なしは plan 全体
61
61
  - **単独**: `plan_key` に `-` を渡し、第6引数へ聞き取ったタスクを書いた本文ファイル(`- タスク名: 何をどこまでやるか` の箇条書き。中間ファイルは scratchpad で可)を渡す。`.team/tasks.md`(読み取り専用の議題表)が生成され、`roles/member.md` は単独版になる。`done.sh` は配られない。**議題表を渡さないと setup.sh はエラーで止まる**(空の議題表を作らない)
62
62
  4. **Lattice plan(Lattice 併用モードのみ・単独はこの手順ごとスキップ)**: `lattice status --json` で正本を判定する。`uninitialized` なら聞き取ったタスクを JSON へ落として `scripts/make-plan-input.mjs <tasks.json> --project <project>` で `plan create` 入力を生成し、`lattice plan create --input .lattice/plan-create.json` を打つ。初期化済みなら `todo migrate` の作法(Lattice 正典)に従う。設計メモは各タスクに必ず書く
63
63
  - `make-plan-input.mjs` が digest 計算と `hard_dependencies` の `(from,to)` 昇順ソートを持つ(**手書きで2回踏んだ罠**。順序が崩れると `INPUT_INVALID / pointer:"/"` としか言われない)。`project_id` の既定は project ディレクトリ名で、`external-pane.mjs` が書く `project.json` の既定と一致させてある——**両者がずれると Lattice が identity 検証で落ちる**
64
64
  - 単独モードのタスク正本は手順3で生成した `.team/tasks.md` だけである。状態(誰が持っているか・何が終わったか)は持たせない——claim と完了は room の宣言だけが正(決定48 の延長)。ミニタスクトラッカーを別途作らない(決定36)
65
- 5. **メンバー起動**: メンバーごとに `env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> --roles <役割>[,<役割>...] [--mission <使命>] [着任指示]` を実行する。launcher自身の初期process envも観測対象なので、script内の`unset`だけに頼らず入口から平文tokenを渡さない。**roles は必須**(02_models の公式役割が1つ以上。未指定・`worker`・未知は着席前に非ゼロ終了)。model 省略時は順位表の着席可能な1位。指定時は表外でも通す。tmux 作成(aiterm と同じソケットなので、立った席はそのまま `pty_read`/`pty_send` で読める)→ credential file path注入 → 起動 → 既知ダイアログ通過 → 着席確認まで1回で行き、着席しなければ最後の画面を出して非ゼロで落ちる(黙って進まない)
65
+ 5. **メンバー起動**: メンバーごとに `env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> --roles <役割>[,<役割>...] [--mission <使命>] [着任指示]` を1回実行する。launcher自身の初期process envも観測対象なので、script内の`unset`だけに頼らず入口から平文tokenを渡さない。**roles は必須**(02_models の公式役割が1つ以上。未指定・`worker`・未知は着席前に非ゼロ終了)。model 省略時は順位表の着席可能な1位。指定時は表外でも通す。tmux作成→credential注入→room成立→3 bridgeの現行版への収束→着任指示→既知ダイアログ通過→実ターン開始観測までを同じ入口が連続実行する。AIがshell/platform入口、bridge順、途中再起動を選ばない。実ターン開始を観測できなければ最後の画面を保存して非ゼロで落ちる
66
66
  - 起動前に `pty_list` で既存の `peer-*` 席を確認する(前の卓の残骸を99席実測したことがある)。同名の席は launch-seat.sh が落としてから立て直す
67
67
  - 着任指示を第6引数に渡すと着席後に送る。文面: 「あなたは「<日本語名>」。.team/roles/member.md を読んで着任し、作業ループを開始せよ。全タスク完了の宣言まで自律的に続けること。」
68
68
  - 席が読む env は script が組み立てる(`PEERTABLE_URL` / `PEERTABLE_ROOM` / `PEERTABLE_MEMBER` / `PEERTABLE_CREDENTIAL_FILE`、Lattice 併用なら `PEERTABLE_PLAN` と actor 3点)。token値は席別`0600` fileにだけ置き、pathだけを席へ渡す。**channels は `--mcp-config` の MCP server を解決しない**(実測 2026-08-08・Claude Code v2.1.226・決定44)ため、room の MCP 定義は setup.sh が project root へ置く `.mcp.json` が正。Peertable管理下fileはlaunch時にもcurrent-tree clientへ同期する。project に既存 `.mcp.json` があった場合は無断更新せず`SEAT_ROOM_MCP_STALE`で止まるので、AI がroom定義を手動mergeしてteardownで復元する
69
69
  - **Codex 席**(`harness=codex`): Codex には channels が無いので、room は `-c` 上書きの stdio MCPとして、同じPeertable treeの`node room/client.mjs`を差す。**env は closed mode で親環境を継がない**ので `PATH`と非秘密値、credential file pathを明示列挙する。effortはmember metadataだけでなく`model_reasoning_effort`へも同じ値を渡す。モデル名は ChatGPT アカウントで使える slug を渡す(`~/.codex/config.toml` の `model` が既定値の参考。使えない slug は起動後の最初のターンで 400 になって初めて分かる)。**DM を席 TUI へ入れる配達は Codex / Grok だけ**(下記)——Claude 席は room client が `notifications/claude/channel` を送る(8/12 に送信ループを削った回帰を 0.4.5 で戻した)。Codex は MCP 通知だけではターンを始めないので、room に残すだけでは席に届いたことにならない
70
70
  - **Grok 席**(`harness=grok`): project rootの`.mcp.json`をGrok Buildが読み、同じroom clientと席固有envをAitermが渡す。**user `~/.grok/config.toml` の booth/lattice 等 MCP は載せない**——席専用 `GROK_HOME`(`.team/seats/<name>.grok-home`、auth.json と ui だけ)を preflight と live 席の両方へ渡す。通らなければ席を立てず、別 model へ落とさない。model / effortはAitermの`grok_agent`へそのまま渡し、`grok models`のlive catalogに無いmodelは着席前に失敗する。初めて開くtreeの既知workspace trustは着席処理が通す。channelsは無いので同じ TUI 配達を使う。Grok TUIの既定はターン中の素送信を今の仕事へ混ぜず入力キューへ積むので、配達はGrok席がidleになるまで送らない
71
- 6. **DM の TUI 配達(Codex / Grok 席がある時)**: `scripts/ensure-bridge.sh <project> wakeup <席名>…`。room の SSE を購読し、明示的にその席宛の新着(自分の発言は除く)だけを席の TUI へ素送信する。専用の起床デーモンではない——Codex / Grok が MCP 通知からターンを始めないための配達口である。ensure は専用 tmux session で起動し、bridge の `ready_at` を待つ。確認できなければログ末尾を出して非ゼロで終わる。**pid が生きているだけでは本人ではない**(Windows pid を再利用する。2026-08-21 実測: 死んだ bridge の pid が oracle-mcp に再利用され、ensure が起動済みと誤認、全員宛キックオフが席へ届かなかった)。live 判定は record の `last_progress_at` が新しいこと。偽の pid は kill しない。Codexは即送信、Grokはidle待ち。親(`parent_watch`)は対象にしない。`observe` 欠落は対象外ではなく配達失敗としてログに出し、再試行する。停止は `node scripts/wakeup-bridge.mjs <project> --stop`(teardown.sh が自動で行う)
71
+ 6. **DM の TUI 配達(Codex / Grok 席がある時)**: wakeup bridgeはsetup/launch/resumeの正規入口が自動で起動・更新する。AIが`ensure-bridge.sh`を個別に呼ぶ段はない。room の SSE を購読し、明示的にその席宛の新着(自分の発言は除く)だけを席の TUI へ素送信する。Codexの既知MCP/command approvalpending DMの有無に関係なく常時処理し、未知dialogは触らない。live 判定はrecordの`last_progress_at`とPeertable版数の両方を使い、旧版bridgeは正規入口が停止→更新→ready確認まで連続実行する
72
72
  - **黙って止まらないための三段**(決定58 の受信側の作法): ①75秒なにも届かなければ自分から切って繋ぎ直す ②繋ぎ直したら `?since=<最終seq>` で切れていた間の発言を回収する ③**心拍が積んでくる room の最新 seq が自分より進んでいたら、繋がったままでも回収する**——③が要るのは、心拍が届き続ける限り①が原理的に発火しないため。**server 側の心拍(`event: ping`・25秒周期)が前提**なので、古い room サーバーへ繋ぐと①だけが効く形になる
73
73
  - ログは `.team/wakeup-bridge.log`。**0件でも0件と出す**ので、TUI へ入れているか・取りこぼしていないかはログを見れば分かる。再現ハーネスは `experiments/bridge-catchup-repro.mjs`
74
74
 
75
- 6.5 **席の稼働状態ブリッジ(setup.sh が自動で起こす)**: 手順3の scaffold が `ensure-bridge.sh <project> seat-status` で起こすので、**AI が手で叩く段は無い**。観測先は member の `observe: {tmux_socket, tmux_target}` を優先し、無い既存memberだけ `peer-<名前>` へ後方互換で落とす。socket は明示env → aiterm POSIX既定 → 検証済み`/tmp/aiterm-*.sock`1本 → 既定パスの順で解決し、bashからは`tmux-socket.mjs`を呼ぶ。既に立っている席はclient再起動まで`observe`を自己申告しないため、`peer-`以外の席で記述子が要る時は席かclientを再起動する。WSLではbridgeが読む**WSL側**の版が正で、Windows側だけの更新では直らない。
75
+ 6.5 **席の稼働状態ブリッジ**: setup/launch/resumeが`ensure-project-runtime.sh`を内部で1回呼び、alarm/seat-status/wakeupを現行版へ収束させるので、**AIが個別bridgeを操作する段は無い**。観測先はmemberの`observe: {tmux_socket, tmux_target}`を優先する。ランプは現在のpaneからbusy/blocked/idle/deadを判定し、通信失敗表示が残るGrok席をbusyにしない
76
76
  6.7 **model / effort変更(本人要請→親実行)**: 本人は希望と理由を自然文で親だけへDMする。親は意味を判断してtargetを確定し、`env -u PEERTABLE_POST_TOKEN skill/scripts/change-seat.sh <project> <member> [--model <model>] [--effort <effort>] [--parent <name>] [--reason <text>]`を実行する。定型文への言い直し、完全一致の再送、本人DMの機械検査は行わない。scriptはroom memberの現在値を読み、Aitermの公開`agent_configure`へ確定targetを渡し、metadataの読返しと変更履歴を残す。targetはlive catalogで検証し、変更後の記録に失敗した場合も成功へ丸めない。
77
77
  - 同じharness内のmodel / effort変更はAitermが同一sessionと会話contextを保って行う。harness変更だけは再起動を伴うため、本人はrole・工程正本・roomログから再着任する
78
78
  6.8 **mission 更新(席が自分で実行)**: 工程が変わったら席が `env -u PEERTABLE_POST_TOKEN skill/scripts/set-mission.sh <project> <name> <text>` を打つ。`POST /members` で chip を更新し、`[mission] <name>: <text>` を全員へ1行出す。席は再起動しない。親は代行しない。`change-seat.sh` に mission を足さない。
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { classifyPaneTail, paneStatusTail } from './seat-usage.mjs'
3
+
4
+ const chunks = []
5
+ for await (const chunk of process.stdin) chunks.push(chunk)
6
+ process.stdout.write(`${classifyPaneTail(paneStatusTail(Buffer.concat(chunks).toString('utf8')))}\n`)
@@ -21,6 +21,8 @@ import { join } from 'node:path'
21
21
  import { promisify } from 'node:util'
22
22
 
23
23
  import { resolvePostToken } from './seat-usage.mjs'
24
+ import { resolveLatticeExecutable } from './seat-usage.mjs'
25
+ import { latticeTaskAvailable } from './alarm-condition.mjs'
24
26
  import { observePidCommand } from './seat-identity.mjs'
25
27
  import { updateBridgeProgress } from './bridge-record-live.mjs'
26
28
 
@@ -53,6 +55,7 @@ if (flag === '--stop') process.exit(0)
53
55
 
54
56
  const setup = JSON.parse(readFileSync(join(team, 'setup-state.json'), 'utf8'))
55
57
  const { room, server_url: url } = setup
58
+ const lattice = setup.lattice_cli ? resolveLatticeExecutable(setup.lattice_cli) : null
56
59
  const token = resolvePostToken(process.env)
57
60
  if (!token) { console.error('ALARM_BRIDGE_TOKEN_MISSING: 書込トークンが無い'); process.exit(1) }
58
61
  mkdirSync(alarmsDir, { recursive: true })
@@ -86,23 +89,37 @@ async function tick() {
86
89
  if (Date.now() < due) continue
87
90
  let reg
88
91
  try { reg = JSON.parse(readFileSync(file, 'utf8')) } catch { continue } // 書込途中は次周へ
89
- if (typeof reg.seat !== 'string' || typeof reg.script !== 'string' || !reg.seat || !reg.script) {
90
- log(`ALARM_REGISTRATION_INVALID: ${entry}(seat/script が無い)を撤去する`)
92
+ const typed = reg.condition?.type === 'lattice_task_ready'
93
+ const legacy = typeof reg.script === 'string' && reg.script.length > 0
94
+ if (typeof reg.seat !== 'string' || !reg.seat || (!typed && !legacy)) {
95
+ log(`ALARM_REGISTRATION_INVALID: ${entry}(seat/condition が無い)を撤去する`)
91
96
  try { unlinkSync(file) } catch {}
92
97
  continue
93
98
  }
94
99
  nextDue.set(file, Date.now() + Math.max(2, Number(reg.interval_s) || 10) * 1000)
95
100
  let met = false
96
101
  let firedOutput = ''
97
- try {
98
- const { stdout, stderr } = await run('bash', ['-c', reg.script], { timeout: 30_000, maxBuffer: 1024 * 1024 })
99
- met = true
100
- // 発火の証拠を残す: fail-open型の条件は「なぜ成立したか」が出力にしか現れない
101
- // (実被弾 2026-08-26: statusの一過性失敗で即発火したが、何が失敗したか記録が無く未解明)
102
- firedOutput = `${stdout ?? ''}${stderr ?? ''}`.trim().slice(0, 300)
103
- } catch (e) {
104
- // exit 非0 = 条件未成立(正常)。timeout/spawn 失敗はログへ(成立と区別し、握りつぶさない)
105
- if (e.killed || e.code === 'ENOENT') log(`条件スクリプトの実行失敗(${entry}): ${e.message}`)
102
+ if (typed) {
103
+ if (!lattice) {
104
+ log(`ALARM_CONDITION_EVALUATION_FAILED: ${entry} lattice_cli がsetup-stateに無い`)
105
+ } else {
106
+ try {
107
+ const { stdout } = await run(lattice.command, lattice.argv, { timeout: 30_000, maxBuffer: 1024 * 1024, cwd: proj })
108
+ const status = JSON.parse(stdout)
109
+ met = latticeTaskAvailable(status, reg.condition.task_id, reg.condition.plan_key ?? '')
110
+ } catch (e) {
111
+ log(`ALARM_CONDITION_EVALUATION_FAILED: ${entry} ${e.message.split('\n')[0]}`)
112
+ }
113
+ }
114
+ } else {
115
+ try {
116
+ const { stdout, stderr } = await run('bash', ['-c', reg.script], { timeout: 30_000, maxBuffer: 1024 * 1024 })
117
+ met = true
118
+ firedOutput = `${stdout ?? ''}${stderr ?? ''}`.trim().slice(0, 300)
119
+ } catch (e) {
120
+ // exit 非0 = 条件未成立(正常)。timeout/spawn 失敗はログへ(成立と区別し、握りつぶさない)
121
+ if (e.killed || e.code === 'ENOENT') log(`条件スクリプトの実行失敗(${entry}): ${e.message}`)
122
+ }
106
123
  }
107
124
  if (!met) continue
108
125
  try {
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ export function latticeTaskAvailable(status, taskId, planKey = '') {
4
+ if (!status || typeof status !== 'object' || typeof taskId !== 'string' || !taskId) return false
5
+ const candidates = [...(status.next_ready ?? []), ...(status.active_set ?? [])]
6
+ return candidates.some(task => task?.task_id === taskId && (!planKey || task?.plan_key === planKey))
7
+ }
@@ -1,14 +1,25 @@
1
1
  #!/bin/bash
2
2
  # 目覚まし係へ待機解放条件を登録する(席が待機に入る前に使う)。
3
- # usage: alarm-set.sh <project_dir> <seat> <note> <script...>
3
+ # usage: alarm-set.sh <project_dir> <seat> <note> --lattice-task-ready <task_id> [--plan <plan_key>]
4
+ # alarm-set.sh <project_dir> <seat> <note> <script...> # 外部条件の後方互換
4
5
  # script は bash -c で実行され、exit 0 で条件成立とみなされる。
5
6
  # **script はシングルクォートで渡すこと**。ダブルクォートだと `$()` や変数が登録側の
6
7
  # shell で先に展開され、判定コマンドでなく展開結果が登録される(かえで実測 2026-08-22)。
7
8
  # 成立すると席へ「[待機解放条件成立] <note>」が配達され、登録は自動で消える。
8
9
  set -eu
9
10
  proj="${1:?project_dir}"; seat="${2:?seat}"; note="${3:?note}"; shift 3
10
- script="$*"
11
- [ -n "$script" ] || { echo "ALARM_SET_SCRIPT_REQUIRED: 条件スクリプトが空" >&2; exit 2; }
11
+ condition_kind=script
12
+ task_id=""; plan_key=""; script=""
13
+ if [ "${1:-}" = "--lattice-task-ready" ]; then
14
+ condition_kind=lattice_task_ready
15
+ task_id="${2:-}"; shift 2
16
+ [ -n "$task_id" ] || { echo "ALARM_SET_TASK_REQUIRED: task_id が空" >&2; exit 2; }
17
+ if [ "${1:-}" = "--plan" ]; then plan_key="${2:-}"; shift 2; fi
18
+ [ "$#" -eq 0 ] || { echo "ALARM_SET_ARGS_INVALID: 余分な引数 $*" >&2; exit 2; }
19
+ else
20
+ script="$*"
21
+ [ -n "$script" ] || { echo "ALARM_SET_SCRIPT_REQUIRED: 条件スクリプトが空" >&2; exit 2; }
22
+ fi
12
23
  # 誤った project path を mkdir -p が幻のツリーとして実体化し、誰も読まない場所へ exit 0 で
13
24
  # 登録が「成功」する(実被弾 2026-08-28: worktree cwd からの相対 'poly' が poly/poly/.team/ を
14
25
  # 生み、席は登録済みと思って15時間停滞)。目覚まし係が読むのは既存卓の .team だけなので、
@@ -18,11 +29,12 @@ proj="$(cd -- "$proj" 2>/dev/null && pwd)" || { echo "ALARM_SET_PROJECT_INVALID:
18
29
  dir="$proj/.team/alarms"
19
30
  mkdir -p "$dir"
20
31
  id="$(date +%s)-$$"
21
- python3 - "$dir/$id.json" "$seat" "$note" "$script" <<'PY'
22
- import json, sys
23
- out, seat, note, script = sys.argv[1:5]
24
- with open(out, 'w') as f:
25
- json.dump({'seat': seat, 'note': note, 'script': script, 'interval_s': 10,
26
- 'created_at': __import__('datetime').datetime.utcnow().isoformat() + 'Z'}, f, ensure_ascii=False)
27
- PY
32
+ script_dir=$(cd "$(dirname "$0")" && pwd -P)
33
+ if [ "$condition_kind" = lattice_task_ready ]; then
34
+ printf '%s\0%s\0%s\0%s\0%s' "$condition_kind" "$seat" "$note" "$task_id" "$plan_key" \
35
+ | node "$script_dir/alarm-write.mjs" "$dir/$id.json"
36
+ else
37
+ printf '%s\0%s\0%s\0%s' "$condition_kind" "$seat" "$note" "$script" \
38
+ | node "$script_dir/alarm-write.mjs" "$dir/$id.json"
39
+ fi
28
40
  echo "ALARM_SET_OK: ${dir}/${id}.json(seat=${seat} note=${note})"
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // Git Bash→Windows native processのargv文字コード変換を通さず、UTF-8 stdinでalarmを保存する。
3
+ import { writeFileSync } from 'node:fs'
4
+
5
+ const out = process.argv[2]
6
+ if (!out) throw new Error('ALARM_WRITE_PATH_REQUIRED')
7
+
8
+ const chunks = []
9
+ for await (const chunk of process.stdin) chunks.push(chunk)
10
+ const fields = Buffer.concat(chunks).toString('utf8').split('\0')
11
+ const kind = fields.shift()
12
+ let payload
13
+ if (kind === 'script' && fields.length === 3 && fields.every(value => value.length > 0)) {
14
+ const [seat, note, script] = fields
15
+ payload = { seat, note, script }
16
+ } else if (kind === 'lattice_task_ready' && fields.length === 4
17
+ && fields.slice(0, 3).every(value => value.length > 0)) {
18
+ const [seat, note, task_id, plan_key] = fields
19
+ payload = { seat, note, condition: { type: 'lattice_task_ready', task_id, ...(plan_key ? { plan_key } : {}) } }
20
+ } else {
21
+ throw new Error('ALARM_WRITE_PAYLOAD_INVALID')
22
+ }
23
+ writeFileSync(out, `${JSON.stringify({
24
+ ...payload,
25
+ interval_s: 10,
26
+ created_at: new Date().toISOString(),
27
+ })}\n`, { encoding: 'utf8', mode: 0o600 })
@@ -10,19 +10,28 @@ export const COMMAND_APPROVAL_DONT_ASK = "don't ask again"
10
10
 
11
11
  export function keysForCodexPane(screen) {
12
12
  const text = String(screen ?? '')
13
- if (text.includes(MCP_ALLOW_NEEDLE) && text.includes(MCP_ALWAYS_ALLOW)) {
13
+ const lines = text.split(/\r?\n/u).slice(-24)
14
+ const tail = lines.join('\n')
15
+ // scrollbackに残った古いdialogより、現在のbusy表示/通常composerを優先する。
16
+ // active dialog自身の選択肢行(`› 1.`)は通常composerとして数えない。
17
+ const busy = /esc to interrupt/iu.test(tail)
18
+ const idleComposer = lines.some(line => /^\s*›(?:\s*$|\s+(?!\d+\.))/u.test(line))
19
+ && lines.some(line => /gpt-[\w.-]+/iu.test(line) && line.includes('·'))
20
+ if (busy || idleComposer) return null
21
+ const dialogText = tail
22
+ if (dialogText.includes(MCP_ALLOW_NEEDLE) && dialogText.includes(MCP_ALWAYS_ALLOW)) {
14
23
  return { kind: 'mcp-allow', keys: ['Down', 'Down', 'Enter'] }
15
24
  }
16
- if (text.includes(COMMAND_APPROVAL_NEEDLE) && text.toLowerCase().includes(COMMAND_APPROVAL_DONT_ASK)) {
25
+ if (dialogText.includes(COMMAND_APPROVAL_NEEDLE) && dialogText.toLowerCase().includes(COMMAND_APPROVAL_DONT_ASK)) {
17
26
  return { kind: 'command-approval', keys: ['Down', 'Enter'] }
18
27
  }
19
- if (text.includes('Hooks need review')) {
28
+ if (dialogText.includes('Hooks need review')) {
20
29
  return { kind: 'hooks', keys: ['Down', 'Enter'] }
21
30
  }
22
- if (text.includes('Update now')) {
31
+ if (dialogText.includes('Update now')) {
23
32
  return { kind: 'update', keys: ['Down', 'Enter'] }
24
33
  }
25
- if (text.includes('Yes, continue') && text.includes('Do you trust the contents of this directory')) {
34
+ if (dialogText.includes('Yes, continue') && dialogText.includes('Do you trust the contents of this directory')) {
26
35
  return { kind: 'trust', keys: ['Enter'] }
27
36
  }
28
37
  return null
@@ -4,6 +4,10 @@ set -euo pipefail
4
4
 
5
5
  proj="$1"; name="$2"; shift 2
6
6
  case "$name" in seat-status) script="seat-status-bridge.mjs" ;; wakeup) script="wakeup-bridge.mjs" ;; alarm) script="alarm-bridge.mjs" ;; run) echo "ENSURE_BRIDGE_RETIRED: run-bridge は退役した(2026-08-22)。介入は席が自分の Lattice コマンド応答で受け取る" >&2; exit 1 ;; *) echo "usage: ensure-bridge.sh <project> <seat-status|wakeup|alarm> [args...]" >&2; exit 1 ;; esac
7
+ scripts_dir=$(cd "$(dirname "$0")" && pwd -P)
8
+ repo=$(cd "$scripts_dir/../.." && pwd -P)
9
+ peertable_version=$(node -p "require(process.argv[1]).version" "$repo/package.json")
10
+ peertable_runtime_digest=$(node "$scripts_dir/runtime-digest.mjs")
7
11
  team="$proj/.team"; record="$team/$name-bridge.json"; log="$team/$name-bridge.log"
8
12
  force=false
9
13
  if [ "${1:-}" = "--force" ]; then force=true; shift; fi
@@ -17,7 +21,16 @@ if [ $# -eq 0 ] && [ -f "$record" ]; then
17
21
  fi
18
22
  fi
19
23
  if [ -f "$record" ]; then
20
- if node "$(dirname "$0")/bridge-record-live.mjs" "$record"; then exit 0; fi
24
+ if node "$scripts_dir/bridge-record-live.mjs" "$record"; then
25
+ record_version=$(node -e 'const x=require(process.argv[1]);process.stdout.write(x.peertable_version||"")' "$record")
26
+ record_digest=$(node -e 'const x=require(process.argv[1]);process.stdout.write(x.peertable_runtime_digest||"")' "$record")
27
+ if [ "$record_version" = "$peertable_version" ] && [ "$record_digest" = "$peertable_runtime_digest" ]; then exit 0; fi
28
+ echo "${name}-bridge: Peertable ${record_version:-unknown} → ${peertable_version} の現行runtimeへ自動更新する" >&2
29
+ node "$scripts_dir/$script" "$proj" --stop || {
30
+ echo "${name}-bridge: 旧版bridgeを停止できないため更新を中止する" >&2
31
+ exit 1
32
+ }
33
+ fi
21
34
  if ! "$force" && grep -q 'WRITE_DENIED' "$log" 2>/dev/null; then echo "${name}-bridge: 前回はWRITE_DENIEDで終了。--forceを指定すること" >&2; exit 1; fi
22
35
  fi
23
36
  # shellcheck disable=SC1091
@@ -49,8 +62,8 @@ if [ "$(node -p 'process.platform')" = "win32" ]; then
49
62
  # psmuxが起動する正規shellはPowerShell 7。POSIXの`env`とMSYS pathを文字列のまま
50
63
  # 渡すとnodeへ到達する前に終了するため、Windows nodeに各pathをargvとして変換させ、
51
64
  # PowerShell EncodedCommandを一箇所で組み立てる。
52
- win_command=$(node "$(dirname "$0")/platform/windows/build-bridge-command.mjs" \
53
- "$(dirname "$0")/$script" "$proj" "$log" "$@")
65
+ win_command=$(node "$scripts_dir/platform/windows/build-bridge-command.mjs" \
66
+ "$scripts_dir/$script" "$proj" "$log" "$@")
54
67
  tmux_at new-session -d -s "$session" "$win_command"
55
68
  else
56
69
  tmux_at new-session -d -s "$session" "env${env_prefix} node $(dirname "$0")/$script $(printf '%q ' "$proj" "$@") >> $(printf '%q' "$log") 2>&1"
@@ -60,10 +73,10 @@ for _ in $(seq 1 30); do
60
73
  # **末尾は本物の改行にする。** `"\\n"` と書くと JS がリテラルの `\`+`n` を足し、
61
74
  # record が JSON として壊れる——`--stop` も次回起動も `JSON.parse` で落ちて、
62
75
  # 「止められない・建て直せない常駐」ができる(2026-08-11 実測)。一時 file→rename で原子的に。
63
- node -e 'const fs=require("fs");const p=process.argv[1],a=process.argv.slice(2);
76
+ node -e 'const fs=require("fs");const p=process.argv[1],version=process.argv[2],digest=process.argv[3],a=process.argv.slice(4);
64
77
  const t=`${p}.${process.pid}.tmp`;
65
- fs.writeFileSync(t,JSON.stringify({...JSON.parse(fs.readFileSync(p,"utf8")),args:a})+"\n");
66
- fs.renameSync(t,p)' "$record" "$@"
78
+ fs.writeFileSync(t,JSON.stringify({...JSON.parse(fs.readFileSync(p,"utf8")),peertable_version:version,peertable_runtime_digest:digest,args:a})+"\n");
79
+ fs.renameSync(t,p)' "$record" "$peertable_version" "$peertable_runtime_digest" "$@"
67
80
  exit 0
68
81
  fi
69
82
  sleep .5
@@ -0,0 +1,13 @@
1
+ #!/bin/bash
2
+ # 円卓runtimeを一回の呼出しで現行Peertable版へ収束させる正規入口。
3
+ set -euo pipefail
4
+
5
+ proj="${1:-}"
6
+ [ -n "$proj" ] || { echo "usage: ensure-project-runtime.sh <project_dir>" >&2; exit 2; }
7
+ scripts=$(cd "$(dirname "$0")" && pwd -P)
8
+
9
+ for kind in alarm seat-status wakeup; do
10
+ "$scripts/ensure-bridge.sh" "$proj" "$kind"
11
+ done
12
+
13
+ echo "peertable runtime ready: alarm / seat-status / wakeup"
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Peertable席のGrokをroom専用にする。GROK_HOMEだけではClaude/Cursor互換源を読むため、
3
+ // compatを明示OFFにし、project .mcp.jsonのroom以外もnative高優先設定でdisableする。
4
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
5
+ import { dirname, join, resolve } from 'node:path'
6
+
7
+ const [projectArg, outputArg] = process.argv.slice(2)
8
+ if (!projectArg || !outputArg) throw new Error('GROK_SEAT_CONFIG_ARGS_INVALID')
9
+ const project = resolve(projectArg)
10
+ const output = resolve(outputArg)
11
+ const rootMcp = JSON.parse(readFileSync(join(project, '.mcp.json'), 'utf8'))
12
+ const servers = rootMcp?.mcpServers
13
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers) || !servers.room) {
14
+ throw new Error('GROK_SEAT_ROOM_MCP_MISSING')
15
+ }
16
+
17
+ const disabled = Object.keys(servers).filter(name => name !== 'room').sort((a, b) => a.localeCompare(b, 'en'))
18
+ const lines = [
19
+ `disabled_mcp_servers = ${JSON.stringify(disabled)}`,
20
+ '',
21
+ '[ui]',
22
+ 'permission_mode = "always-approve"',
23
+ '',
24
+ '[compat.claude]',
25
+ 'skills = false',
26
+ 'rules = false',
27
+ 'agents = false',
28
+ 'mcps = false',
29
+ 'hooks = false',
30
+ 'sessions = false',
31
+ '',
32
+ '[compat.cursor]',
33
+ 'skills = false',
34
+ 'rules = false',
35
+ 'agents = false',
36
+ 'mcps = false',
37
+ 'hooks = false',
38
+ 'sessions = false',
39
+ '',
40
+ '[compat.codex]',
41
+ 'sessions = false',
42
+ ]
43
+ mkdirSync(dirname(output), { recursive: true, mode: 0o700 })
44
+ const temp = `${output}.${process.pid}.tmp`
45
+ writeFileSync(temp, `${lines.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 })
46
+ chmodSync(temp, 0o600)
47
+ renameSync(temp, output)
@@ -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
  *)
@@ -590,6 +598,11 @@ if [ "$room_ready" != true ]; then
590
598
  exit 1
591
599
  fi
592
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
+
593
606
  if [ "$harness" = codex ]; then
594
607
  # member 登録後の最初の tool 呼び出しで MCP Allow が出る。出ている間だけ通し、沈黙5秒で抜ける。
595
608
  codex_post_ready_deadline=$((SECONDS + 90))
@@ -609,37 +622,16 @@ if [ "$harness" = codex ]; then
609
622
  done
610
623
  fi
611
624
  if [ "$brief_dispatched" = true ]; then
612
- brief_completed=true
613
625
  echo "briefed: ${sess}(Aiterm launch prompt)"
614
626
  fi
615
627
 
616
628
  if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
617
629
  if [ "$brief_in_composer" != true ]; then
618
- brief_ready_deadline=$((SECONDS + 90))
619
- brief_ready_streak=0
620
- brief_ready=false
621
- while [ $SECONDS -lt "$brief_ready_deadline" ]; do
622
- brief_ready_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
623
- if [ "$harness" = codex ] && pass_codex_pane "$brief_ready_screen"; then
624
- brief_ready_streak=0
625
- sleep 1
626
- continue
627
- fi
628
- if [ "$harness" != codex ] || printf '%s\n' "$brief_ready_screen" | node -e '
629
- let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const l=s.split(/\r?\n/).slice(-24);const p=l.some(x=>x.trim()==="›"||x.trimStart().startsWith("› "));const f=l.some(x=>/gpt-/.test(x)&&x.includes("·"));process.exit(p&&f?0:1)})'; then
630
- brief_ready_streak=$((brief_ready_streak + 1))
631
- if [ "$brief_ready_streak" -ge 11 ]; then brief_ready=true; break; fi
632
- else
633
- brief_ready_streak=0
634
- fi
635
- sleep 1
636
- done
637
- if [ "$brief_ready" != true ]; then
638
- echo "LAUNCH_BRIEF_NOT_READY: Aiterm dispatch前に入力promptの安定を観測できない" >&2
639
- exit 1
640
- fi
641
- if node "$peertable_script_dir/aiterm-send.mjs" "$sess" "$brief_file" >/dev/null; then
642
- 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")
643
635
  echo "briefed: $sess(Aiterm pty_send)"
644
636
  else
645
637
  echo "LAUNCH_BRIEF_SEND_FAILED: Aiterm pty_sendでbriefをdispatchできない" >&2
@@ -666,14 +658,15 @@ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
666
658
  sleep 1
667
659
  done
668
660
  if [ "$brief_ready" != true ]; then
669
- brief_not_ready=true
670
- 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
671
665
  else
672
666
  # prompt の描画とキー入力受理の境界を分ける。Codex のTUIが入力欄を
673
667
  # mountした直後に paste と Enter を同一tickで受けると、Enterだけ落ちる。
674
668
  sleep 1
675
669
 
676
- brief_before=$(tmux_at capture-pane -S -1000 -t "$sess" -p 2>/dev/null || true)
677
670
  # submit_residue の席は本文が composer に残っている。貼り直すと二重になるので Enter だけ打つ。
678
671
  if [ "$brief_in_composer" != true ]; then
679
672
  brief_buffer="peertable-brief-${name}-$$"
@@ -693,28 +686,46 @@ if [ -n "$brief" ] && [ "$brief_dispatched" != true ]; then
693
686
  exit 1
694
687
  fi
695
688
 
696
- # 入力欄へ置けた事実だけでは着任成功としない。既存の席状態判定と同じ live marker が、
697
- # brief 投入後に画面へ現れたことを観測する。高速な fake/CLI の残像を拾わないよう、投入前の
698
- # 画面と異なることも同時に要求する。dispatch は Aiterm の手動送信と同じく、この1回だけ行う。
699
- brief_deadline=$((SECONDS + 30))
700
- brief_turn_started=false
701
- while [ $SECONDS -lt "$brief_deadline" ]; do
702
- brief_screen=$(tmux_at capture-pane -S -1000 -t "$sess" -p 2>/dev/null || true)
703
- case "$brief_screen" in
704
- *"esc to interrupt"*)
705
- if [ "$brief_screen" != "$brief_before" ]; then brief_turn_started=true; break; fi
706
- ;;
707
- 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
708
719
  sleep 1
709
720
  done
710
- if [ "$brief_turn_started" != true ]; then
711
- 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
712
725
  exit 1
713
726
  fi
714
727
  brief_completed=true
715
- echo "briefed: $sess"
716
- fi
717
- fi
728
+ echo "active: $sess(実ターン開始を観測)"
718
729
  fi
719
730
 
720
731
  # 席の本人性(pid / 起動時刻 / argv digest)を room 台帳の member 行へ登録する。
@@ -783,28 +794,5 @@ else
783
794
  echo "SEAT_LEDGER_INCOMPLETE: 台帳の member 行に素性または本人性が欠けている(席は着席済み。doctor で確認)" >&2
784
795
  fi
785
796
 
786
- PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" alarm || echo "alarm-bridge の起動確認に失敗した(席は着席済み)" >&2
787
- if PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" seat-status; then
788
- echo "seat-status-bridge: 起動確認済み"
789
- else
790
- echo "seat-status-bridge の起動確認に失敗した(席は着席済み)" >&2
791
- fi
792
-
793
- # Claude 席の新着は room client の notifications/claude/channel。TUI 配達は channels を持たない
794
- # Codex / Grok 席だけ。Claude に立てると channel と二重配達になる。
795
- if [ "$harness" = codex ] || [ "$harness" = grok ]; then
796
- if PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" wakeup; then
797
- echo "wakeup-bridge: TUI配達 起動確認済み"
798
- else
799
- echo "SEAT_WAKEUP_BRIDGE_NOT_READY: Codex/Grok席の TUI 配達を準備できない" >&2
800
- exit 1
801
- fi
802
- fi
803
-
804
797
  if [ -z "$brief" ]; then brief_completed=true; fi
805
798
  credential_persist=true
806
- if [ "$brief_not_ready" = true ]; then
807
- # 空席の後段セットアップ(identity / metadata / bridge)まで済ませたうえで、
808
- # 呼び出し側にはready未確認を非0で返す。席はAiterm手動dispatchへ引き継ぐ。
809
- exit 1
810
- fi
@@ -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 {
@@ -5,6 +5,7 @@
5
5
  // python stdout の cp932 は日本語本文を壊す)。
6
6
  import { readFileSync, existsSync } from 'node:fs'
7
7
  import { resolve } from 'node:path'
8
+ import { resolvePostToken } from './seat-usage.mjs'
8
9
 
9
10
  const args = process.argv.slice(2)
10
11
  const buildOnly = args[0] === '--build-only'
@@ -35,7 +36,7 @@ if (!url || !room) {
35
36
  process.stderr.write('POST_MESSAGE_TARGET_UNRESOLVED: PEERTABLE_URL/PEERTABLE_ROOM も cwd の .team/setup-state.json も無く、送信先を決められない\n')
36
37
  process.exit(1)
37
38
  }
38
- const token = process.env.PEERTABLE_POST_TOKEN ?? null
39
+ const token = resolvePostToken(process.env) || null
39
40
 
40
41
  const headers = { 'content-type': 'application/json' }
41
42
  if (token !== null) headers['x-peertable-token'] = token
@@ -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,164 @@
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
+ })
159
+
160
+ test('親post入口は明示envだけでなく共通token解決を使う', () => {
161
+ const source = readFileSync(new URL('./post-message.mjs', import.meta.url), 'utf8')
162
+ assert.ok(source.includes("import { resolvePostToken } from './seat-usage.mjs'"))
163
+ assert.ok(source.includes('resolvePostToken(process.env)'))
164
+ })
@@ -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 を先に見る)。
@@ -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"
@@ -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'
@@ -313,6 +313,22 @@ function advanceLastSeq() {
313
313
  }
314
314
 
315
315
  const deferredBusy = new Set()
316
+ async function passKnownCodexDialog(member) {
317
+ if (memberHarness(member) !== 'codex') return false
318
+ if (!isWakeupBridgeTarget(member, targetOpts)) return false
319
+ const observation = resolveSeatObservation(member, null)
320
+ if (observation === null) return false
321
+ const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
322
+ const dialog = keysForCodexPane(String(pane.stdout ?? ''))
323
+ if (!dialog) return false
324
+ for (const key of dialog.keys) {
325
+ await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, key], { socket: observation.socket }))
326
+ await sleep(150)
327
+ }
328
+ log(`Codex ${dialog.kind} を通した: ${member.name}`)
329
+ return true
330
+ }
331
+
316
332
  async function wake(seat, msgs) {
317
333
  const last = msgs[msgs.length - 1]
318
334
  const text = msgs.map(formatWakeNotice).join(' || ')
@@ -365,18 +381,9 @@ async function wake(seat, msgs) {
365
381
  throw error
366
382
  }
367
383
  }
384
+ if (await passKnownCodexDialog(member)) return 'deferred'
368
385
  const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
369
386
  const screen = String(pane.stdout ?? '')
370
- if (memberHarness(member) === 'codex') {
371
- const dialog = keysForCodexPane(screen)
372
- if (dialog) {
373
- for (const key of dialog.keys) {
374
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, key], { socket: observation.socket }))
375
- }
376
- log(`Codex ${dialog.kind} を通した: ${seat}`)
377
- return 'deferred'
378
- }
379
- }
380
387
  if (memberHarness(member) === 'grok') {
381
388
  const tail = screen.split('\n').slice(-14).join('\n')
382
389
  if (shouldDeferGrokWake(memberHarness(member), tail)) {
@@ -563,6 +570,25 @@ setInterval(() => {
563
570
  }
564
571
  }, 2000)
565
572
 
573
+ // CodexのMCP/command approvalはroom発言の配達時だけでなく、席自身の最初のtool実行でも
574
+ // 遅れて現れる。pending DMが無い時も既知dialogだけを処理し続け、ランプがblockedのまま
575
+ // 放置される状態を作らない。未知dialogはkeysForCodexPaneがnullを返すため触らない。
576
+ let dialogSweepRunning = false
577
+ setInterval(async () => {
578
+ if (dialogSweepRunning) return
579
+ dialogSweepRunning = true
580
+ try {
581
+ for (const member of members.values()) {
582
+ if (member.status_effective === 'dead') continue
583
+ try { await passKnownCodexDialog(member) } catch (error) {
584
+ log(`CODEX_DIALOG_SWEEP_FAILED: ${member.name} ${error.message}`)
585
+ }
586
+ }
587
+ } finally {
588
+ dialogSweepRunning = false
589
+ }
590
+ }, 2000)
591
+
566
592
  // 再接続で取りこぼさないために、全宛先の成功が揃った最大 seq を持つ。
567
593
  // 失敗した宛先がある限り lastSeq は前進せず、GET since から同じseqを回収する。
568
594
  let dispatchQueue = Promise.resolve()
@@ -14,7 +14,7 @@
14
14
 
15
15
  **次にやる仕事があるターンを終える直前に `post(to: "<自分の名前>", message: "[次の行動] ...")` を1回送れ。** この自己DMは席の TUI へ次ターンの入力として入る。仕事があるのに出さないと席は止まる。空の終了通知は使うな。
16
16
  **手番が無く待機に入るときは `[次の行動]` 自己DMを出すな。** 親へ `[待機]` を一度だけ送り、沈黙する。再開は inbound だけ。待機自己DMは自分を起こし無限ループになる(2026-08-20 実測)。
17
- **解放条件のある待機は、寝る前に目覚まし係へ条件を登録する(2026-08-22 オーナー設計)。** `"$(dirname "$0")"` 相当の `.team/scripts/alarm-set.sh <project> <自分の名前> "<何の条件か>" "<exit 0 で成立になる判定コマンド>"` を実行してから待機する。条件が成立すると `[待機解放条件成立]` が届いて起きる。**他席への「終わったら教えて」という口約束に解放を依存させない**(相手の死・退席・レース負けで永眠する)。**結果待ちのポーリングに自己DMやターンを使わない**——判定は目覚まし係の shell が無償で回す。**判定コマンドは終了・死亡・失敗でも exit 0 になる形で書く(2026-08-26 オーナー裁定)。**「プロセス存続∧時間経過」のような成功だけの条件は、ジョブが死ぬと永久に成立せず誰も死を検知しない(録画デーモン死亡の実被弾)。「正常完了した」ではなく「見に行くべき状態変化が起きた」を条件にする。
17
+ **解放条件のある待機は、寝る前に目覚まし係へ条件を登録する(2026-08-22 オーナー設計)。** Lattice task待ちは `.team/scripts/alarm-set.sh <project> <自分の名前> "<何の条件か>" --lattice-task-ready <task_id> --plan <plan_key>` だけを使う。JSON判定とCLI起動は装置が所有し、shellのgrep・引用・fail-openを自作しない。条件が成立すると `[待機解放条件成立]` が届いて起きる。外部job等の任意条件だけは後方互換の判定commandを使い、終了・死亡・失敗でも「見に行くべき状態変化」としてexit 0になる形にする。**他席への「終わったら教えて」という口約束や自己DMポーリングへ解放を依存させない**。
18
18
  **停止宣言なしにターンを終えると継続番犬に起こされる。** busy が2分以上続いた席が `[待機]`/`[監査提出]` を出さずに idle 化すると `[継続]` が届く。作業を落としたなら続行し、手番が無いなら待機を宣言し直す。
19
19
 
20
20
  **作業を選ぶのも始めるのもあなたである。** 装置から仕事が降ってくることはないし、着手前に装置の許可を待つこともない(オーナー裁定 2026-08-09・改・裁定1)。Lattice が居る卓では、着手した後に装置が競合を見て介入してくることがある——それは次の「装置が介入してきた時」に従う。