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 +2 -1
- package/room/client.mjs +17 -6
- package/room/message-bounds.mjs +55 -0
- package/skill/SKILL.md +4 -4
- package/skill/scripts/agent-pane-status.mjs +6 -0
- package/skill/scripts/alarm-bridge.mjs +28 -11
- package/skill/scripts/alarm-condition.mjs +7 -0
- package/skill/scripts/alarm-set.sh +22 -10
- package/skill/scripts/alarm-write.mjs +27 -0
- package/skill/scripts/codex-dialog.mjs +14 -5
- package/skill/scripts/ensure-bridge.sh +19 -6
- package/skill/scripts/ensure-project-runtime.sh +13 -0
- package/skill/scripts/grok-seat-config.mjs +47 -0
- package/skill/scripts/launch-seat.sh +64 -76
- package/skill/scripts/platform/windows/build-bridge-command.mjs +7 -2
- package/skill/scripts/post-message.mjs +2 -1
- package/skill/scripts/resume.sh +2 -9
- package/skill/scripts/runtime-contract.test.mjs +164 -0
- package/skill/scripts/runtime-digest.mjs +25 -0
- package/skill/scripts/seat-status-bridge.mjs +2 -2
- package/skill/scripts/seat-usage.mjs +8 -0
- package/skill/scripts/setup.sh +3 -6
- package/skill/scripts/upgrade-team-assets.sh +4 -0
- package/skill/scripts/vendors/grok/pane-status.mjs +7 -0
- package/skill/scripts/wakeup-bridge.mjs +36 -10
- package/skill/templates/member.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peertable",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
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(
|
|
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
|
|
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]`
|
|
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 <使命>] [着任指示]`
|
|
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 席がある時)**:
|
|
71
|
+
6. **DM の TUI 配達(Codex / Grok 席がある時)**: wakeup bridgeはsetup/launch/resumeの正規入口が自動で起動・更新する。AIが`ensure-bridge.sh`を個別に呼ぶ段はない。room の SSE を購読し、明示的にその席宛の新着(自分の発言は除く)だけを席の TUI へ素送信する。Codexの既知MCP/command approvalはpending 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
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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> <
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
28
|
+
if (dialogText.includes('Hooks need review')) {
|
|
20
29
|
return { kind: 'hooks', keys: ['Down', 'Enter'] }
|
|
21
30
|
}
|
|
22
|
-
if (
|
|
31
|
+
if (dialogText.includes('Update now')) {
|
|
23
32
|
return { kind: 'update', keys: ['Down', 'Enter'] }
|
|
24
33
|
}
|
|
25
|
-
if (
|
|
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 "$
|
|
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 "$
|
|
53
|
-
"$
|
|
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(
|
|
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 ] && [ "$
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
670
|
-
echo "LAUNCH_BRIEF_NOT_READY: brief を受け付ける入力
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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 [ "$
|
|
711
|
-
|
|
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 "
|
|
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}
|
|
33
|
-
statements.push('
|
|
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
|
|
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
|
package/skill/scripts/resume.sh
CHANGED
|
@@ -150,15 +150,8 @@ done < <(node -e '
|
|
|
150
150
|
' "$seats_file")
|
|
151
151
|
rm -f "$seats_file"
|
|
152
152
|
|
|
153
|
-
# Phase C:
|
|
154
|
-
|
|
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
|
|
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 を先に見る)。
|
package/skill/scripts/setup.sh
CHANGED
|
@@ -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-
|
|
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 オーナー設計)。**
|
|
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 が居る卓では、着手した後に装置が競合を見て介入してくることがある——それは次の「装置が介入してきた時」に従う。
|