peertable 0.4.10 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/package.json +1 -1
- package/room/client.mjs +3 -1
- package/skill/SKILL.md +4 -4
- package/skill/scripts/aiterm-configure.mjs +1 -1
- package/skill/scripts/aiterm-launch.mjs +1 -1
- package/skill/scripts/change-seat.sh +8 -4
- package/skill/scripts/ensure-bridge.sh +5 -2
- package/skill/scripts/ensure-codex-room-mcp.mjs +1 -0
- package/skill/scripts/launch-seat.sh +96 -12
- package/skill/scripts/parent-watch.mjs +16 -6
- package/skill/scripts/pid-alive.mjs +10 -0
- package/skill/scripts/resolve-seat-placement.mjs +160 -0
- package/skill/scripts/seat-identity.mjs +35 -13
package/README.ja.md
CHANGED
|
@@ -107,9 +107,9 @@ Codex では、スキルが所有する room MCP block をプロジェクトの
|
|
|
107
107
|
|
|
108
108
|
動いており、**自分自身の開発に使っている**。2026-08-08 に end-to-end 検証済み——オーケストレーターなしの完全な一周(2 メンバーが相談し、claim し、インターフェースを交渉し、見つけた罠を共有して小さなプロジェクトを出荷)を**外部介入ゼロ**で完走。2026-08-13の実席ライフサイクルでは、作業席が親を通じてsession contextを保ったままmodel / effortを変更し、再起動後はroomと工程正本から再着任した。2026-08-14にはGrok 4.6席の着席、room参加、同一sessionの4.6↔4.5変更、DM起床を実機で確認した。2026-08-17にGrok席はidle待ち、broadcastは本文を残し、tmuxの無い親でbridge cursorが止まらないよう直した。
|
|
109
109
|
|
|
110
|
-
現在のnpm releaseは **peertable 0.4.
|
|
110
|
+
現在のnpm releaseは **peertable 0.4.12**。
|
|
111
111
|
|
|
112
|
-
設計文書と決定履歴(**
|
|
112
|
+
設計文書と決定履歴(**91 決定**)は [docs/plan.md](docs/plan.md)。
|
|
113
113
|
|
|
114
114
|
Claude Code channels はリサーチプレビューのため、フラグ・プロトコルは変わりうる。
|
|
115
115
|
|
package/README.md
CHANGED
|
@@ -143,9 +143,9 @@ It interviews you, names the members, scaffolds `.team/` (charter + roles, isola
|
|
|
143
143
|
|
|
144
144
|
Working, and used to build itself. First verified end-to-end on 2026-08-08 with a full no-orchestrator loop: two members consulted, claimed, negotiated an interface, shared a discovered pitfall, and shipped a small project with **zero external intervention**. A 2026-08-13 real-seat lifecycle verified in-place model/effort changes and restart recovery. On 2026-08-14, a Grok 4.6 seat joined the room, changed 4.6↔4.5 in the same session, and woke on a direct message in a live acceptance run. On 2026-08-17 the wake-up path was corrected so Grok seats wait for idle, broadcasts keep their body, and a parent without a tmux seat cannot stall the bridge cursor.
|
|
145
145
|
|
|
146
|
-
The current npm release is **peertable 0.4.
|
|
146
|
+
The current npm release is **peertable 0.4.12**.
|
|
147
147
|
|
|
148
|
-
The design document and decision log (**
|
|
148
|
+
The design document and decision log (**91 decisions**, in Japanese) live in [docs/plan.md](docs/plan.md).
|
|
149
149
|
|
|
150
150
|
Depends on Claude Code **channels**, currently a research preview — flags and protocol may change.
|
|
151
151
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peertable",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
4
4
|
"description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code, Codex, and Grok sessions into a team of equal, long-lived peers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/room/client.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
11
11
|
|
|
12
12
|
// client.mjs 側のハードコード版数。package.json の version と一致していることを
|
|
13
13
|
// diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
|
|
14
|
-
const MCP_VERSION = '0.4.
|
|
14
|
+
const MCP_VERSION = '0.4.12'
|
|
15
15
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
16
16
|
|
|
17
17
|
const USAGE = `usage:
|
|
@@ -287,9 +287,11 @@ async function runDiagnostics(asJson) {
|
|
|
287
287
|
'scripts/teardown.sh',
|
|
288
288
|
'scripts/external-pane.mjs',
|
|
289
289
|
'scripts/launch-seat.sh',
|
|
290
|
+
'scripts/resolve-seat-placement.mjs',
|
|
290
291
|
'scripts/tmux-at.bash',
|
|
291
292
|
'scripts/tmux-socket.mjs',
|
|
292
293
|
'scripts/seat-identity.mjs',
|
|
294
|
+
'scripts/pid-alive.mjs',
|
|
293
295
|
'scripts/parent-watch.mjs',
|
|
294
296
|
'scripts/seat-credential.mjs',
|
|
295
297
|
'scripts/ensure-room-mcp.mjs',
|
package/skill/SKILL.md
CHANGED
|
@@ -28,7 +28,7 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
|
|
|
28
28
|
## Peertableの正規席と委譲入口
|
|
29
29
|
|
|
30
30
|
Peertableのメンバー席は、aiterm-mcpの外部PTYに長寿命で着席させる。新しい席は
|
|
31
|
-
`env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> <
|
|
31
|
+
`env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> <role> [brief]`で起こし、既存席の分担・起床・確認は
|
|
32
32
|
`mcp__aiterm__pty_read` / `mcp__aiterm__pty_send` / `mcp__aiterm__pty_key`、roomの`read_unread` / `post`、
|
|
33
33
|
Latticeの`todo`で行う。通常shellのために開いた短命PTYと、メンバーが着席する長寿命PTYは別物である。
|
|
34
34
|
|
|
@@ -49,7 +49,7 @@ script は内部で Aiterm の公開 `claude_agent` / `codex_agent` / `grok_agen
|
|
|
49
49
|
|
|
50
50
|
手順は **聞き取り → script → 着任指示** の3段である。scripts が機械部分を全部持つので、AI が手で tmux を組み立てることはしない。
|
|
51
51
|
|
|
52
|
-
1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** /
|
|
52
|
+
1. **聞き取り**: 対象プロジェクトのパス / **工程正本(`Lattice 併用`=既定 / `単独`)** / メンバー数と**役割**(dotagents `docs/02_models.md` の役割名そのもの。未指定・未知は着席しない。model×effort は `launch-seat.sh` が順位表1位から機械解決する——呼び出し側が model / vendor / effort を渡して正本を迂回しない。決定49改・決定91)/ 初期タスク群(何を作るか)/ room 名(既定: プロジェクトのディレクトリ名)/ **公開URL基底**(Lattice 併用のみ。外部ペインに書く URL。クオ環境は `https://peertable.kitepon.dev`。未指定なら room サーバーの URL がそのまま入る=LAN URL は Lattice を外から見た時に開けない)
|
|
53
53
|
- **メンバー数の既定**: Lattice 併用なら plan compile 結果の幅(`max_frontier_width`)に合わせる(実測: 幅3→3人、第2 campaign で幅4→4人目追加)。frontier より多い席は最初から遊ぶ。単独モードには frontier が無いので既定の根拠も無く、聞き取りで決める
|
|
54
54
|
- **運用中のworker席数の標準は「ready+activeな実装ToDo数」(決定68)**: 監査専任席はworker数、reclaim、scale-down候補へ含めず、最終試験結果を待って監査を担う。claimできるToDoが無いworker席は仕事を発明せず、最終手段として親だけへ待機DMする
|
|
55
55
|
- **モードの選び分け**: タスク間に依存があり並列境界の機械保証が要るなら Lattice 併用。依存の無い小規模作業で、対象プロジェクトに Lattice を持ち込みたくないなら単独。単独で失うのは task 間スケジューリングの機械保証だけで、円卓の核(room・憲章・宣言による協力)は変わらない(決定47)
|
|
@@ -61,7 +61,7 @@ script は内部で Aiterm の公開 `claude_agent` / `codex_agent` / `grok_agen
|
|
|
61
61
|
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 正典)に従う。設計メモは各タスクに必ず書く
|
|
62
62
|
- `make-plan-input.mjs` が digest 計算と `hard_dependencies` の `(from,to)` 昇順ソートを持つ(**手書きで2回踏んだ罠**。順序が崩れると `INPUT_INVALID / pointer:"/"` としか言われない)。`project_id` の既定は project ディレクトリ名で、`external-pane.mjs` が書く `project.json` の既定と一致させてある——**両者がずれると Lattice が identity 検証で落ちる**
|
|
63
63
|
- 単独モードのタスク正本は手順3で生成した `.team/tasks.md` だけである。状態(誰が持っているか・何が終わったか)は持たせない——claim と完了は room の宣言だけが正(決定48 の延長)。ミニタスクトラッカーを別途作らない(決定36)
|
|
64
|
-
5. **メンバー起動**: メンバーごとに `env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> <
|
|
64
|
+
5. **メンバー起動**: メンバーごとに `env -u PEERTABLE_POST_TOKEN scripts/launch-seat.sh <project> <name> <role> [着任指示]` を実行する。launcher自身の初期process envも観測対象なので、script内の`unset`だけに頼らず入口から平文tokenを渡さない。**role は必須**(02_models の役割名。未指定・`worker` 既定・未知の名前は着席前に非ゼロ終了)。model / vendor / effort は順位表から解決する。tmux 作成(aiterm と同じソケットなので、立った席はそのまま `pty_read`/`pty_send` で読める)→ credential file path注入 → 起動 → 既知ダイアログ通過 → 着席確認まで1回で行き、着席しなければ最後の画面を出して非ゼロで落ちる(黙って進まない)
|
|
65
65
|
- 起動前に `pty_list` で既存の `peer-*` 席を確認する(前の卓の残骸を99席実測したことがある)。同名の席は launch-seat.sh が落としてから立て直す
|
|
66
66
|
- 着任指示を第6引数に渡すと着席後に送る。文面: 「あなたは「<日本語名>」。.team/roles/member.md を読んで着任し、作業ループを開始せよ。全タスク完了の宣言まで自律的に続けること。」
|
|
67
67
|
- 席が読む 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で復元する
|
|
@@ -230,7 +230,7 @@ witness をどう生成するかは**対象 project 側の作法に従う**(La
|
|
|
230
230
|
- evidence は記述子 JSON。記述子ファイル自体も repo 内相対パスに置く(repo 外絶対パスは INVALID_ARGUMENTS)。`.team/scripts/done.sh` が正規経路。証跡の置き場は **`evidence/<plan_key>/<task_id>.md`**——task_id は campaign を跨いで再利用されるので、平置きにすると前の campaign の監査証跡を上書きで消す(2026-08-08 実測)
|
|
231
231
|
- **外部ペイン(決定53)は Lattice 0.50.0 以降が要る。** それ以前の Lattice に `external_pane` 入りの `project.json` を差すと、identity 検証が完全一致キーで落ちて `lattice todo status` ごと死ぬ(`PROJECT_IDENTITY_INVALID / identity_schema_invalid`・0.49.0 で実測)。工程正本が読めなくなる=卓が止まるので、Lattice が古い環境では Lattice 併用 setup を走らせない
|
|
232
232
|
- **メンバー起動の既知ダイアログは2種だけ**(実測 2026-08-08): 未信頼ディレクトリの workspace trust(`1. Yes, I trust this folder`)と開発 channel 警告(`1. I am using this for local development`)。`--dangerously-skip-permissions` を付けているので MCP 同意ダイアログは出ない。信頼済みディレクトリでは trust も出ない
|
|
233
|
-
- **Codex 席のダイアログは
|
|
233
|
+
- **Codex 席のダイアログは3種で、更新案内と hooks の既定は誤り**(Codex CLI v0.146.0 および 2026-08-20 実測): ディレクトリ trust(`1. Yes, continue`=既定で正しい)。**更新案内(`1. Update now`)は既定のまま Enter を押すと立卓の途中で `npm install -g @openai/codex` が走る**ので「2. Skip」。**`Hooks need review` は room MCP 初期化より前に出る**(`SEAT_ROOM_MCP_NOT_READY` のまま rollback)。`launch-seat.sh` は「2. Trust all and continue」を通す。失敗時は rollback 前に `.team/<name>-pane-on-fail.txt` を残す。待ち時間のポーリングで代用しない
|
|
234
234
|
- **Codex はターン実行中でも素送信を受け付ける**(実測 2026-08-08)。busy 中に送った文言はそのターンの中で読まれ、指示どおりに動く(steering)。Codex 席の起床は idle 待ちを持たない
|
|
235
235
|
- **Grok はターン実行中の素送信を今の仕事へ混ぜない**(実測 2026-08-17・Grok Build TUI 既定 `follow_up_behavior=queue`)。届いた文は入力キューへ積まれ、次の user ターンになる。起床ブリッジは Grok 席だけ pane が idle になるまで送らない。busy の判定は `esc to interrupt`、待ち中の `send a message to interrupt`、番号付きキュー+`Enter:send now`
|
|
236
236
|
- **席の沈黙は「詰まり」と同義ではない。** 発言間隔やファイルの更新時刻から止まったと判定しない——実装が終わって検証に時間を使っているだけのことがある。判定は `tmux_at capture-pane -t peer-<名前> -p`(POSIX は `tmux -S <sock>`、Windows psmux は `tmux -L aiterm-<hash>`。決定88)で**実状態を読む**: 画面に **`esc to interrupt` が在れば長いターンの最中**(通知はターン後にまとめて届くので、呼びかけを足しても速くならない)/選択ダイアログで止まっているなら既知の停止要因/`pane_dead=1` なら落ちている。**スピナーの動名詞(`Cogitating…` 等)で判定しない**——毎回ランダムなので語そのものは使わない(2026-08-08 実測)。Fable のツール実行中は `… (7m 48s` の経過時間行と `/btw` の `without interrupting Claude's current work` が固定句として出る。思考中は `thinking with` / `almost done thinking`。**`esc to interrupt` は Claude 席のステータス行にも Codex 席の `Working (…)` にも入る共通マーカー**で、vendor 分岐が要らない。**読み取りだけなら相手の作業を壊さない**ので、憶測を room へ流す前にこれを見る(2026-08-08 に2人が独立に踏み、先に憶測を流した側が訂正を出した)
|
|
@@ -16,7 +16,7 @@ if (!session_id || (!model && !reasoning_effort)) {
|
|
|
16
16
|
// Aiterm のmanaged session metadataは実行時state root(macOSではTMPDIR)にある。
|
|
17
17
|
// stdio transportの既定環境は親のTMPDIRを落とすため、同じ公開serverへ明示継承する。
|
|
18
18
|
const transport = new StdioClientTransport({ command: 'aiterm-mcp', env: process.env })
|
|
19
|
-
const client = new Client({ name: 'peertable-seat-configure', version: '0.4.
|
|
19
|
+
const client = new Client({ name: 'peertable-seat-configure', version: '0.4.12' })
|
|
20
20
|
await client.connect(transport)
|
|
21
21
|
const result = await client.callTool({
|
|
22
22
|
name: 'agent_configure',
|
|
@@ -8,7 +8,7 @@ if (!session_name || !['claude', 'codex', 'grok'].includes(vendor) || !model ||
|
|
|
8
8
|
throw new Error('SEAT_AITERM_LAUNCH_ARGS_INVALID')
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const client = new Client({ name: 'peertable-seat-launch', version: '0.4.
|
|
11
|
+
const client = new Client({ name: 'peertable-seat-launch', version: '0.4.12' })
|
|
12
12
|
await client.connect(new StdioClientTransport({ command: 'aiterm-mcp', env: process.env }))
|
|
13
13
|
const env_vars = [
|
|
14
14
|
'PEERTABLE_URL', 'PEERTABLE_ROOM', 'PEERTABLE_MEMBER', 'PEERTABLE_CREDENTIAL_FILE',
|
|
@@ -59,9 +59,9 @@ name=sys.argv[1]
|
|
|
59
59
|
member=next((m for m in json.load(sys.stdin).get("members",[]) if m.get("name")==name),None)
|
|
60
60
|
if not member or member.get("vendor") not in ("claude","codex","grok") or not member.get("model"):
|
|
61
61
|
raise SystemExit(1)
|
|
62
|
-
print("\t".join((member["vendor"],member["model"],member.get("effort") or "",member.get("aiterm_session_id") or "")))
|
|
62
|
+
print("\t".join((member["vendor"],member["model"],member.get("effort") or "",member.get("aiterm_session_id") or "",member.get("role") or "")))
|
|
63
63
|
' "$name") || { echo "SEAT_CHANGE_MEMBER_METADATA_MISSING: ${name} のvendor/modelが要る" >&2; exit 1; }
|
|
64
|
-
IFS=$'\t' read -r old_vendor old_model old_effort aiterm_session_id <<EOF
|
|
64
|
+
IFS=$'\t' read -r old_vendor old_model old_effort aiterm_session_id old_role <<EOF
|
|
65
65
|
$meta
|
|
66
66
|
EOF
|
|
67
67
|
|
|
@@ -202,11 +202,15 @@ else
|
|
|
202
202
|
exit 1
|
|
203
203
|
fi
|
|
204
204
|
launch="$script_dir/launch-seat.sh"
|
|
205
|
+
[ -n "$old_role" ] || {
|
|
206
|
+
echo "SEAT_CHANGE_ROLE_MISSING: ${name} の role が無い(02_models の役割名が要る)" >&2
|
|
207
|
+
exit 1
|
|
208
|
+
}
|
|
205
209
|
brief="席設定が変更され(${changes})、席を再起動しました。.team/roles/member.mdと工程正本・roomログから再着任し、進行中taskを続けてください。"
|
|
206
|
-
if ! "$launch" "$proj" "$name" "$
|
|
210
|
+
if ! "$launch" "$proj" "$name" "$old_role" "$brief" --model "$model" --vendor "$vendor" --effort "$effort"; then
|
|
207
211
|
echo "SEAT_CHANGE_RESTART_FAILED: ${changes}。旧設定(vendor=${old_vendor} / model=${old_model} / effort=${old_effort:-default})へrollbackする" >&2
|
|
208
212
|
rollback_brief="席設定の変更に失敗して旧設定へrollbackしました。.team/roles/member.mdと工程正本・roomログから再着任してください。"
|
|
209
|
-
if "$launch" "$proj" "$name" "$
|
|
213
|
+
if "$launch" "$proj" "$name" "$old_role" "$rollback_brief" --model "$old_model" --vendor "$old_vendor" --effort "$old_effort"; then
|
|
210
214
|
echo "SEAT_CHANGE_ROLLED_BACK: ${name} は vendor=${old_vendor} / model=${old_model} / effort=${old_effort:-default} で再着席" >&2
|
|
211
215
|
else
|
|
212
216
|
echo "SEAT_CHANGE_ROLLBACK_FAILED: ${name} の席を手動で復旧する必要がある" >&2
|
|
@@ -18,7 +18,7 @@ if [ $# -eq 0 ] && [ -f "$record" ]; then
|
|
|
18
18
|
fi
|
|
19
19
|
if [ -f "$record" ]; then
|
|
20
20
|
pid=$(node -e 'try{process.stdout.write(String(require(process.argv[1]).pid||""))}catch{}' "$record")
|
|
21
|
-
if [ -n "$pid" ] &&
|
|
21
|
+
if [ -n "$pid" ] && node "$(dirname "$0")/pid-alive.mjs" "$pid"; then exit 0; fi
|
|
22
22
|
if ! "$force" && grep -q 'WRITE_DENIED' "$log" 2>/dev/null; then echo "${name}-bridge: 前回はWRITE_DENIEDで終了。--forceを指定すること" >&2; exit 1; fi
|
|
23
23
|
fi
|
|
24
24
|
# shellcheck disable=SC1091
|
|
@@ -30,7 +30,10 @@ session="peertable-${name}-${room}"
|
|
|
30
30
|
# 新しい bridge が1バイトも動いていない段階で success を返す——「起動していないのに
|
|
31
31
|
# 起動したと言う」=この supervisor が塞ぐはずの穴そのものになる。
|
|
32
32
|
rm -f "$record"
|
|
33
|
-
:
|
|
33
|
+
if ! node -e 'import { writeFileSync } from "node:fs"; writeFileSync(process.argv[1], "")' "$log"; then
|
|
34
|
+
echo "${name}-bridge: log を切り詰められないので追記する" >&2
|
|
35
|
+
: >> "$log" || true
|
|
36
|
+
fi
|
|
34
37
|
# **手渡された env を常駐へ渡す。** 新しい session が継ぐのは tmux *server* の環境であって
|
|
35
38
|
# 呼び出し元 client の環境ではないので、素で起こすと `PEERTABLE_TMUX_SOCKET` の手渡しが黙って消え、
|
|
36
39
|
# 常駐が別の socket(本番の既定)を観測しにいく(2026-08-11 実測)。決定73 と同じ形の裏返しである。
|
|
@@ -64,6 +64,7 @@ function expectedBlock(addedNewline) {
|
|
|
64
64
|
const explicitEnv = seatEnvNames
|
|
65
65
|
.filter((name) => process.env[name] !== undefined && process.env[name] !== '')
|
|
66
66
|
.map((name) => `${name} = ${JSON.stringify(process.env[name])}`)
|
|
67
|
+
if (process.env.PATH) explicitEnv.unshift(`PATH = ${JSON.stringify(process.env.PATH)}`)
|
|
67
68
|
return [
|
|
68
69
|
`# BEGIN PEERTABLE ROOM MCP added_newline=${addedNewline ? '1' : '0'}`,
|
|
69
70
|
'[mcp_servers.room]',
|
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# 席を1つ立てる(tmux 作成 → env 注入 → エージェント起動 → 既知ダイアログ通過 → 着席確認)。
|
|
3
|
-
# usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort>
|
|
4
|
-
#
|
|
5
|
-
# effort:
|
|
3
|
+
# usage: launch-seat.sh <project_dir> <name> <role> [brief] [--model <model> --vendor <vendor> --effort <effort>]
|
|
4
|
+
# role: 必須。dotagents docs/02_models.md の役割名(未指定・未知は着席前に拒否)
|
|
5
|
+
# model / vendor / effort: 省略時は 02_models 順位表から機械解決する。上書きするなら3つとも必須
|
|
6
6
|
# brief: 着席が成立したら送る着任指示(省略時は送らない)
|
|
7
7
|
#
|
|
8
8
|
# room 名・server URL・モード・plan key は <project>/.team/setup-state.json から読む(setup.sh の後に呼ぶ)。
|
|
9
9
|
# 書込トークンは helper が ~/.config/peertable.env から席別 0600 file へ移し、pathだけを席へ渡す。
|
|
10
10
|
# tmux は aiterm-mcp と同じソケットへ作るので、立てた席はそのまま pty_read / pty_send で読める。
|
|
11
11
|
set -e
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
echo "usage: launch-seat.sh <project_dir> <name> <model> <vendor> <effort> [brief]" >&2; exit 1;
|
|
12
|
+
usage() {
|
|
13
|
+
echo "usage: launch-seat.sh <project_dir> <name> <role> [brief] [--model <model> --vendor <vendor> --effort <effort>]" >&2
|
|
15
14
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
proj="$1"; name="$2"; role="$3"
|
|
16
|
+
[ -n "$proj" ] && [ -n "$name" ] && [ -n "$role" ] || { usage; exit 1; }
|
|
17
|
+
shift 3
|
|
18
|
+
brief=""
|
|
19
|
+
opt_model=""; opt_vendor=""; opt_effort=""
|
|
20
|
+
while [ $# -gt 0 ]; do
|
|
21
|
+
case "$1" in
|
|
22
|
+
--model) opt_model="${2:-}"; shift 2 ;;
|
|
23
|
+
--vendor) opt_vendor="${2:-}"; shift 2 ;;
|
|
24
|
+
--effort) opt_effort="${2:-}"; shift 2 ;;
|
|
25
|
+
--*) echo "SEAT_LAUNCH_ARGS_INVALID: 不明な引数 $1" >&2; usage; exit 1 ;;
|
|
26
|
+
*)
|
|
27
|
+
if [ -z "$brief" ]; then brief="$1"; shift
|
|
28
|
+
else echo "SEAT_LAUNCH_ARGS_INVALID: 余分な引数 $1" >&2; usage; exit 1
|
|
29
|
+
fi
|
|
30
|
+
;;
|
|
20
31
|
esac
|
|
32
|
+
done
|
|
33
|
+
if [ -n "$opt_model$opt_vendor$opt_effort" ]; then
|
|
34
|
+
[ -n "$opt_model" ] && [ -n "$opt_vendor" ] && [ -n "$opt_effort" ] || {
|
|
35
|
+
echo "SEAT_LAUNCH_PLACEMENT_INCOMPLETE: --model / --vendor / --effort は3つ揃える" >&2
|
|
36
|
+
exit 1
|
|
37
|
+
}
|
|
21
38
|
fi
|
|
22
|
-
case "$role" in worker|auditor) ;; *) echo "unknown seat role: ${role}(worker / auditor)" >&2; exit 1 ;; esac
|
|
23
39
|
|
|
24
40
|
if [ -n "${PEERTABLE_MEMBER:-}" ]; then
|
|
25
41
|
echo "SEAT_LAUNCH_DELEGATED_CHILD_FORBIDDEN: PEERTABLE_MEMBER=${PEERTABLE_MEMBER} を継承した呼出元からの席起動を拒否" >&2
|
|
@@ -33,6 +49,7 @@ credential_helper="${PEERTABLE_CREDENTIAL_HELPER:-$(dirname "$0")/seat-credentia
|
|
|
33
49
|
room_mcp_helper="${PEERTABLE_ROOM_MCP_HELPER:-$(dirname "$0")/ensure-room-mcp.mjs}"
|
|
34
50
|
codex_room_mcp_helper="${PEERTABLE_CODEX_ROOM_MCP_HELPER:-$(dirname "$0")/ensure-codex-room-mcp.mjs}"
|
|
35
51
|
aiterm_launch_helper="${PEERTABLE_AITERM_LAUNCH_HELPER:-$(dirname "$0")/aiterm-launch.mjs}"
|
|
52
|
+
placement_helper="${PEERTABLE_PLACEMENT_HELPER:-$(dirname "$0")/resolve-seat-placement.mjs}"
|
|
36
53
|
# shellcheck disable=SC1091
|
|
37
54
|
. "$(dirname "$0")/tmux-at.bash"
|
|
38
55
|
peertable_repo=$(cd "$(dirname "$0")/../.." && pwd -P)
|
|
@@ -41,6 +58,32 @@ credential_file=""
|
|
|
41
58
|
credential_persist=false
|
|
42
59
|
aiterm_session_id=""
|
|
43
60
|
|
|
61
|
+
if [ -n "$opt_model" ]; then
|
|
62
|
+
model="$opt_model"; vendor="$opt_vendor"; effort="$opt_effort"
|
|
63
|
+
echo "SEAT_PLACEMENT_OVERRIDE: ${role} → ${vendor} / ${model} / ${effort}"
|
|
64
|
+
else
|
|
65
|
+
placement=$(node "$placement_helper" "$role") || exit "$?"
|
|
66
|
+
IFS=$'\t' read -r model vendor effort rank <<EOF
|
|
67
|
+
$(python3 -c 'import json,sys;p=json.load(sys.stdin);print("\t".join((p["model"],p["vendor"],p.get("effort") or "",str(p["rank"]))))' <<<"$placement")
|
|
68
|
+
EOF
|
|
69
|
+
echo "SEAT_PLACEMENT: ${role} → ${vendor} / ${model}${effort:+ / $effort}(02_models ${rank}位)"
|
|
70
|
+
python3 -c 'import json,sys
|
|
71
|
+
p=json.load(sys.stdin)
|
|
72
|
+
for item in p.get("dropped") or []:
|
|
73
|
+
print("SEAT_PLACEMENT_DROPPED: rank %s %s %s" % (item.get("rank"), item.get("reason"), item.get("cell") or ""), file=sys.stderr)
|
|
74
|
+
' <<<"$placement" || true
|
|
75
|
+
fi
|
|
76
|
+
case "$vendor" in
|
|
77
|
+
claude|codex|grok) ;;
|
|
78
|
+
*) echo "SEAT_LAUNCH_VENDOR_UNSUPPORTED: vendor=${vendor}" >&2; exit 1 ;;
|
|
79
|
+
esac
|
|
80
|
+
if [ "$vendor" = "claude" ] && [ -n "$effort" ]; then
|
|
81
|
+
case "$effort" in
|
|
82
|
+
low|medium|high|xhigh|max) ;;
|
|
83
|
+
*) echo "unknown effort: ${effort}(claude は low|medium|high|xhigh|max)" >&2; exit 1 ;;
|
|
84
|
+
esac
|
|
85
|
+
fi
|
|
86
|
+
|
|
44
87
|
# brief は tmux のコマンド引数へ直接載せない。長さを着席前に検証してから一時 file へ置き、
|
|
45
88
|
# tmux の buffer 経由で貼る。入力を受理できない時は model preflight・tmux・member 登録を
|
|
46
89
|
# 一つも行わず、何が拒否されたかを機械的に読める形で返す。
|
|
@@ -328,10 +371,49 @@ room_ready_deadline=$((SECONDS + 30))
|
|
|
328
371
|
room_ready=false
|
|
329
372
|
grok_trust_accepted=false
|
|
330
373
|
mcp_consent_accepted=false
|
|
374
|
+
codex_hooks_accepted=false
|
|
375
|
+
codex_update_accepted=false
|
|
376
|
+
codex_trust_accepted=false
|
|
331
377
|
while [ $SECONDS -lt "$room_ready_deadline" ]; do
|
|
332
378
|
# Grok Build は初めて開く作業treeで、room MCPを初期化する前にworkspace trustを尋ねる。
|
|
333
379
|
# Peertableが正式に着席させるtreeなので、この既知文言だけを一度通す。未知の確認画面を
|
|
334
380
|
# 汎用的に承認するfallbackにはしない。承認後のMCP初期化時間は改めて30秒確保する。
|
|
381
|
+
if [ "$vendor" = codex ]; then
|
|
382
|
+
codex_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
|
|
383
|
+
if [ "$codex_update_accepted" != true ]; then
|
|
384
|
+
case "$codex_screen" in
|
|
385
|
+
*"Update now"*)
|
|
386
|
+
if tmux_at send-keys -t "$sess" Down && tmux_at send-keys -t "$sess" Enter; then
|
|
387
|
+
codex_update_accepted=true
|
|
388
|
+
room_ready_deadline=$((SECONDS + 30))
|
|
389
|
+
echo "codex update prompt: skipped"
|
|
390
|
+
fi
|
|
391
|
+
;;
|
|
392
|
+
esac
|
|
393
|
+
fi
|
|
394
|
+
if [ "$codex_hooks_accepted" != true ]; then
|
|
395
|
+
case "$codex_screen" in
|
|
396
|
+
*"Hooks need review"*)
|
|
397
|
+
if tmux_at send-keys -t "$sess" Down && tmux_at send-keys -t "$sess" Enter; then
|
|
398
|
+
codex_hooks_accepted=true
|
|
399
|
+
room_ready_deadline=$((SECONDS + 30))
|
|
400
|
+
echo "codex hooks prompt: trust all"
|
|
401
|
+
fi
|
|
402
|
+
;;
|
|
403
|
+
esac
|
|
404
|
+
fi
|
|
405
|
+
if [ "$codex_trust_accepted" != true ]; then
|
|
406
|
+
case "$codex_screen" in
|
|
407
|
+
*"Yes, continue"*)
|
|
408
|
+
if tmux_at send-keys -t "$sess" Enter; then
|
|
409
|
+
codex_trust_accepted=true
|
|
410
|
+
room_ready_deadline=$((SECONDS + 30))
|
|
411
|
+
echo "codex directory trust: accepted"
|
|
412
|
+
fi
|
|
413
|
+
;;
|
|
414
|
+
esac
|
|
415
|
+
fi
|
|
416
|
+
fi
|
|
335
417
|
if [ "$vendor" = grok ] && [ "$grok_trust_accepted" != true ]; then
|
|
336
418
|
grok_screen=$(tmux_at capture-pane -t "$sess" -p 2>/dev/null || true)
|
|
337
419
|
case "$grok_screen" in
|
|
@@ -371,6 +453,8 @@ while [ $SECONDS -lt "$room_ready_deadline" ]; do
|
|
|
371
453
|
done
|
|
372
454
|
if [ "$room_ready" != true ]; then
|
|
373
455
|
echo "SEAT_ROOM_MCP_NOT_READY: room member登録を観測できない(無関係MCP warningとroom不成立を分離。席をrollbackする)" >&2
|
|
456
|
+
tmux_at capture-pane -t "$sess" -p >"$proj/.team/${name}-pane-on-fail.txt" 2>/dev/null || true
|
|
457
|
+
echo "pane saved: $proj/.team/${name}-pane-on-fail.txt" >&2
|
|
374
458
|
exit 1
|
|
375
459
|
fi
|
|
376
460
|
echo "room ready: ${room}/${name}"
|
|
@@ -460,7 +544,7 @@ seat_pid=""
|
|
|
460
544
|
pane_pid=$(tmux_at list-panes -t "$sess" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
|
|
461
545
|
seat_ident=""
|
|
462
546
|
if [ -n "$pane_pid" ]; then
|
|
463
|
-
seat_ident=$(node "$(dirname "$0")/seat-identity.mjs" "$pane_pid"
|
|
547
|
+
seat_ident=$(node "$(dirname "$0")/seat-identity.mjs" "$pane_pid") || seat_ident=""
|
|
464
548
|
fi
|
|
465
549
|
if [ -n "$seat_ident" ]; then
|
|
466
550
|
seat_pid=$(python3 -c "import json,sys;print(json.load(sys.stdin)['pid'])" <<<"$seat_ident")
|
|
@@ -120,6 +120,16 @@ let state = await ensurePrimed()
|
|
|
120
120
|
if (mode === '--prime') process.exit(0)
|
|
121
121
|
|
|
122
122
|
const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
|
|
123
|
+
function stopPid(pid) {
|
|
124
|
+
try { process.kill(pid, 'SIGTERM') } catch { return }
|
|
125
|
+
const deadline = Date.now() + 2000
|
|
126
|
+
while (Date.now() < deadline && alive(pid)) {
|
|
127
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
|
|
128
|
+
}
|
|
129
|
+
if (alive(pid)) {
|
|
130
|
+
try { process.kill(pid, 'SIGKILL') } catch {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
123
133
|
function acquireLock() {
|
|
124
134
|
try {
|
|
125
135
|
writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 })
|
|
@@ -127,13 +137,13 @@ function acquireLock() {
|
|
|
127
137
|
if (error.code !== 'EEXIST') throw error
|
|
128
138
|
let owner = null
|
|
129
139
|
try { owner = Number(readFileSync(lockPath, 'utf8').trim()) } catch {}
|
|
130
|
-
if (Number.isSafeInteger(owner) &&
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
140
|
+
if (Number.isSafeInteger(owner) && owner === process.pid) return
|
|
141
|
+
if (Number.isSafeInteger(owner) && alive(owner)) {
|
|
142
|
+
process.stderr.write(`PARENT_WATCH_REPLACING: pid ${owner}\n`)
|
|
143
|
+
stopPid(owner)
|
|
134
144
|
}
|
|
135
|
-
|
|
136
|
-
process.
|
|
145
|
+
try { unlinkSync(lockPath) } catch {}
|
|
146
|
+
writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 })
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
function releaseLock() {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Windows Git Bash の kill -0 は Win32 pid を見ない。Node の process.kill は見る。
|
|
3
|
+
const pid = Number(process.argv[2])
|
|
4
|
+
if (!Number.isInteger(pid) || pid <= 0) process.exit(2)
|
|
5
|
+
try {
|
|
6
|
+
process.kill(pid, 0)
|
|
7
|
+
process.exit(0)
|
|
8
|
+
} catch {
|
|
9
|
+
process.exit(1)
|
|
10
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 02_models.md の役割→1位〜3位から、着席可能な vendor / model / effort を解決する。
|
|
3
|
+
// 具体モデル名はここに持たない。台帳と順位表をその場で読む。
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { dirname, join, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const EFFORT = /×\s*(none|low|medium|high|xhigh|max|ultra)/iu
|
|
10
|
+
const vendorOf = (modelName) => {
|
|
11
|
+
const name = String(modelName).trim()
|
|
12
|
+
if (/^claude\b/iu.test(name)) return 'claude'
|
|
13
|
+
if (/^gpt\b|^gpt-/iu.test(name)) return 'codex'
|
|
14
|
+
if (/^grok\b/iu.test(name)) return 'grok'
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const fail = (code, message) => {
|
|
19
|
+
process.stderr.write(`${code}: ${message}\n`)
|
|
20
|
+
process.exit(2)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function findModelsDoc({ env = process.env, exists = existsSync, scriptDir = here } = {}) {
|
|
24
|
+
if (env.PEERTABLE_MODELS_DOC) return resolve(env.PEERTABLE_MODELS_DOC)
|
|
25
|
+
if (env.DOTAGENTS_ROOT) return join(resolve(env.DOTAGENTS_ROOT), 'docs/02_models.md')
|
|
26
|
+
const sibling = resolve(scriptDir, '../../../dotagents/docs/02_models.md')
|
|
27
|
+
if (exists(sibling)) return sibling
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const stripCell = (cell) => String(cell ?? '').replace(/\*\*/g, '').trim()
|
|
32
|
+
|
|
33
|
+
const parseMarkdownTable = (block) => {
|
|
34
|
+
const lines = block.split('\n').map((line) => line.trim()).filter((line) => line.startsWith('|'))
|
|
35
|
+
if (lines.length < 3) return []
|
|
36
|
+
const headers = lines[0].split('|').slice(1, -1).map((cell) => stripCell(cell))
|
|
37
|
+
return lines.slice(2).map((line) => {
|
|
38
|
+
const cells = line.split('|').slice(1, -1).map((cell) => stripCell(cell))
|
|
39
|
+
return Object.fromEntries(headers.map((header, index) => [header, cells[index] ?? '']))
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const section = (markdown, heading) => {
|
|
44
|
+
const start = markdown.indexOf(`## ${heading}`)
|
|
45
|
+
if (start < 0) return ''
|
|
46
|
+
const rest = markdown.slice(start)
|
|
47
|
+
const next = rest.search(/\n## /)
|
|
48
|
+
return next < 0 ? rest : rest.slice(0, next)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const slugOf = (cell) => {
|
|
52
|
+
const alias = cell.match(/alias\s+`([^`]+)`/u)
|
|
53
|
+
if (alias) return alias[1]
|
|
54
|
+
const slug = cell.match(/`([^`]+)`/u)
|
|
55
|
+
return slug ? slug[1] : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseLedger(markdown) {
|
|
59
|
+
const rows = parseMarkdownTable(section(markdown, 'モデル台帳'))
|
|
60
|
+
return rows.flatMap((row) => {
|
|
61
|
+
const name = row['モデル']
|
|
62
|
+
const slug = slugOf(row['slug'] ?? '')
|
|
63
|
+
const vendor = vendorOf(name)
|
|
64
|
+
if (!name || !slug || !vendor) return []
|
|
65
|
+
return [{ name, slug, vendor }]
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parseCell = (cell) => {
|
|
70
|
+
const raw = stripCell(cell)
|
|
71
|
+
if (!raw || raw === '—' || raw === '-') return null
|
|
72
|
+
if (raw.includes('オーナー指定')) return { skip: 'owner-pin', cell: raw }
|
|
73
|
+
if (/^ChatGPT/iu.test(raw) || raw.includes('gpt-connector')) return { skip: 'not-a-seat', cell: raw }
|
|
74
|
+
const effortMatch = raw.match(EFFORT)
|
|
75
|
+
const modelKey = raw.split('×')[0]
|
|
76
|
+
.replace(/([^)]*)/gu, '')
|
|
77
|
+
.replace(/\([^)]*\)/gu, '')
|
|
78
|
+
.trim()
|
|
79
|
+
if (!modelKey) return { skip: 'unparseable', cell: raw }
|
|
80
|
+
return {
|
|
81
|
+
modelKey,
|
|
82
|
+
effort: effortMatch ? effortMatch[1].toLowerCase() : null,
|
|
83
|
+
cell: raw,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const matchLedger = (modelKey, ledger) => {
|
|
88
|
+
const key = modelKey.toLowerCase()
|
|
89
|
+
const exact = ledger.find((row) => row.name.toLowerCase() === key)
|
|
90
|
+
if (exact) return exact
|
|
91
|
+
const contained = ledger.filter((row) => row.name.toLowerCase().includes(key) || key.includes(row.name.toLowerCase()))
|
|
92
|
+
if (contained.length === 1) return contained[0]
|
|
93
|
+
const token = ledger.filter((row) => row.name.toLowerCase().split(/\s+/u).includes(key))
|
|
94
|
+
if (token.length === 1) return token[0]
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveSeatPlacement(role, markdown, { source = '' } = {}) {
|
|
99
|
+
const wanted = String(role ?? '').trim()
|
|
100
|
+
if (!wanted) {
|
|
101
|
+
return { error: 'SEAT_ROLE_REQUIRED', message: 'role が空(02_models の役割名が要る)' }
|
|
102
|
+
}
|
|
103
|
+
const ranks = parseMarkdownTable(section(markdown, '順位表(役割→1位〜3位)'))
|
|
104
|
+
const row = ranks.find((item) => item['役割'] === wanted)
|
|
105
|
+
if (!row) {
|
|
106
|
+
const known = ranks.map((item) => item['役割']).filter(Boolean)
|
|
107
|
+
return { error: 'SEAT_ROLE_UNKNOWN', message: `未知の役割: ${wanted}(${known.join(' / ')})` }
|
|
108
|
+
}
|
|
109
|
+
const ledger = parseLedger(markdown)
|
|
110
|
+
const dropped = []
|
|
111
|
+
for (const rank of [1, 2, 3]) {
|
|
112
|
+
const parsed = parseCell(row[`${rank}位`])
|
|
113
|
+
if (!parsed) continue
|
|
114
|
+
if (parsed.skip) {
|
|
115
|
+
dropped.push({ rank, reason: parsed.skip, cell: parsed.cell })
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const hit = matchLedger(parsed.modelKey, ledger)
|
|
119
|
+
if (!hit) {
|
|
120
|
+
dropped.push({ rank, reason: 'not-in-ledger', cell: parsed.cell })
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (hit.slug === 'haiku') {
|
|
124
|
+
return { role: wanted, rank, vendor: hit.vendor, model: hit.slug, effort: '', source, dropped }
|
|
125
|
+
}
|
|
126
|
+
if (!parsed.effort) {
|
|
127
|
+
dropped.push({ rank, reason: 'effort-missing', cell: parsed.cell })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
role: wanted,
|
|
132
|
+
rank,
|
|
133
|
+
vendor: hit.vendor,
|
|
134
|
+
model: hit.slug,
|
|
135
|
+
effort: parsed.effort,
|
|
136
|
+
source,
|
|
137
|
+
dropped,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
error: 'SEAT_PLACEMENT_UNRESOLVABLE',
|
|
142
|
+
message: `${wanted} を着席可能な vendor/model/effort へ解決できない`,
|
|
143
|
+
dropped,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
148
|
+
if (isMain) {
|
|
149
|
+
const role = process.argv[2]
|
|
150
|
+
const doc = findModelsDoc()
|
|
151
|
+
if (!doc) {
|
|
152
|
+
fail('SEAT_MODELS_DOC_MISSING', '02_models.md が見つからない(PEERTABLE_MODELS_DOC または DOTAGENTS_ROOT を渡す)')
|
|
153
|
+
}
|
|
154
|
+
if (!existsSync(doc)) {
|
|
155
|
+
fail('SEAT_MODELS_DOC_MISSING', `02_models.md が無い: ${doc}`)
|
|
156
|
+
}
|
|
157
|
+
const result = resolveSeatPlacement(role, readFileSync(doc, 'utf8'), { source: doc })
|
|
158
|
+
if (result.error) fail(result.error, result.message)
|
|
159
|
+
process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
160
|
+
}
|
|
@@ -2,15 +2,29 @@
|
|
|
2
2
|
// pane_pid から Lattice attach 用の pid / lstart / argv を1件に決める。
|
|
3
3
|
// POSIX は ps。Windows は Win32_Process(ps -Ao は Git Bash で unknown option -- A)。
|
|
4
4
|
import { execFileSync } from 'node:child_process'
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
if (!/^\d+$/.test(panePid || '')) {
|
|
8
|
-
console.error('usage: seat-identity.mjs <pane_pid>')
|
|
9
|
-
process.exit(2)
|
|
10
|
-
}
|
|
5
|
+
import { resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
11
7
|
|
|
12
8
|
const AGENT = /(?:claude|codex|grok|composer)(?:\.cmd|\.exe)?(?:\s|$)/iu
|
|
13
9
|
|
|
10
|
+
export function parseWinCreationDate(value) {
|
|
11
|
+
if (value instanceof Date) {
|
|
12
|
+
if (Number.isNaN(value.getTime())) throw new Error('pid の CreationDate を解釈できない')
|
|
13
|
+
return value.toISOString()
|
|
14
|
+
}
|
|
15
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
16
|
+
const parsed = new Date(value)
|
|
17
|
+
if (Number.isNaN(parsed.getTime())) throw new Error('pid の CreationDate を解釈できない')
|
|
18
|
+
return parsed.toISOString()
|
|
19
|
+
}
|
|
20
|
+
const text = String(value ?? '').trim()
|
|
21
|
+
const microsoft = /^\/Date\((-?\d+)(?:[+-]\d+)?\)\/$/u.exec(text)
|
|
22
|
+
if (microsoft) return new Date(Number(microsoft[1])).toISOString()
|
|
23
|
+
const parsed = new Date(text)
|
|
24
|
+
if (Number.isNaN(parsed.getTime())) throw new Error(`pid の CreationDate を解釈できない: ${text}`)
|
|
25
|
+
return parsed.toISOString()
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
function posixIdentity(pid) {
|
|
15
29
|
const table = execFileSync('ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8' })
|
|
16
30
|
const leaders = table.split('\n').flatMap((line) => {
|
|
@@ -43,16 +57,24 @@ function winIdentity(pid) {
|
|
|
43
57
|
if (AGENT.test(String(self.CommandLine || ''))) chosen = self
|
|
44
58
|
else if (agentKids.length === 1) chosen = agentKids[0]
|
|
45
59
|
else if (children.length === 1) chosen = children[0]
|
|
46
|
-
const started = chosen.CreationDate ?
|
|
60
|
+
const started = chosen.CreationDate ? parseWinCreationDate(chosen.CreationDate) : ''
|
|
47
61
|
const argv = String(chosen.CommandLine || '').trim()
|
|
48
62
|
if (!started || !argv) throw new Error('pid の CreationDate/CommandLine を観測できない')
|
|
49
63
|
return { pid: Number(chosen.ProcessId), started_identity: started, argv }
|
|
50
64
|
}
|
|
51
65
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
process.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
|
67
|
+
if (isCli) {
|
|
68
|
+
const panePid = process.argv[2]
|
|
69
|
+
if (!/^\d+$/.test(panePid || '')) {
|
|
70
|
+
console.error('usage: seat-identity.mjs <pane_pid>')
|
|
71
|
+
process.exit(2)
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const ident = process.platform === 'win32' ? winIdentity(panePid) : posixIdentity(panePid)
|
|
75
|
+
process.stdout.write(`${JSON.stringify(ident)}\n`)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(error.message || error)
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
58
80
|
}
|