peertable 0.4.37 → 0.5.1
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 +3 -1
- package/README.md +3 -1
- package/package.json +2 -2
- package/room/Dockerfile +2 -1
- package/room/client.mjs +5 -12
- package/room/server.mjs +121 -39
- package/skill/SKILL.md +13 -8
- package/skill/scripts/alarm-bridge.mjs +117 -0
- package/skill/scripts/alarm-set.sh +22 -0
- package/skill/scripts/change-seat.sh +2 -1
- package/skill/scripts/codex-dialog.mjs +46 -0
- package/skill/scripts/codex-seat-toml.mjs +6 -0
- package/skill/scripts/doctor.sh +185 -0
- package/skill/scripts/ensure-bridge.sh +1 -1
- package/skill/scripts/ensure-codex-room-mcp.mjs +4 -1
- package/skill/scripts/launch-seat.sh +141 -120
- package/skill/scripts/leave-seat.sh +1 -4
- package/skill/scripts/parent-join.sh +22 -9
- package/skill/scripts/parent-watch-logic.mjs +33 -0
- package/skill/scripts/parent-watch.mjs +34 -1
- package/skill/scripts/pull-attach-input.mjs +53 -0
- package/skill/scripts/refresh-seat-identity.mjs +97 -0
- package/skill/scripts/seat-identity.mjs +37 -6
- package/skill/scripts/seat-status-bridge.mjs +34 -0
- package/skill/scripts/seat-usage.mjs +5 -0
- package/skill/scripts/setup.sh +5 -0
- package/skill/scripts/teardown.sh +8 -6
- package/skill/scripts/wakeup-bridge.mjs +91 -2
- package/skill/scripts/wakeup-delivery.mjs +1 -1
- package/skill/templates/member.md +10 -5
- package/skill/scripts/run-bridge.mjs +0 -272
- package/skill/scripts/seat-metadata.mjs +0 -21
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 台帳(room member 行)の argv_digest を、Lattice attach と同じ /bin/ps -o command= 観測へ揃える。
|
|
3
|
+
// pid / lstart が台帳と違うときは書き換えず終わる(pid 推定も再利用も禁止)。
|
|
4
|
+
import { execFileSync, execFile } from 'node:child_process'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join, resolve } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { promisify } from 'node:util'
|
|
9
|
+
|
|
10
|
+
// ps 観測と digest 計算は seat-identity.mjs(OS観測ライブラリ)が唯一の所有者。再実装しない。
|
|
11
|
+
export { hashArgv, observePidCommand } from './seat-identity.mjs'
|
|
12
|
+
import { observePidCommand } from './seat-identity.mjs'
|
|
13
|
+
|
|
14
|
+
export function refreshSeatRecord(raw, observed, recordedAt) {
|
|
15
|
+
if (!Number.isSafeInteger(raw?.pid) || raw.pid < 1) {
|
|
16
|
+
const error = new Error('台帳の member 行に pid が無い')
|
|
17
|
+
error.code = 'SEAT_IDENTITY_NO_PID'
|
|
18
|
+
throw error
|
|
19
|
+
}
|
|
20
|
+
if (observed.pid !== raw.pid) {
|
|
21
|
+
const error = new Error(`live pid ${observed.pid} は台帳の pid ${raw.pid} と違う`)
|
|
22
|
+
error.code = 'SEAT_IDENTITY_PID_CHANGED'
|
|
23
|
+
throw error
|
|
24
|
+
}
|
|
25
|
+
if (observed.started_identity !== raw.started_identity) {
|
|
26
|
+
const error = new Error('pid の lstart が台帳と一致しない')
|
|
27
|
+
error.code = 'SEAT_IDENTITY_LSTART_CHANGED'
|
|
28
|
+
throw error
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
argv_digest: observed.argv_digest,
|
|
32
|
+
name: raw.name,
|
|
33
|
+
pid: raw.pid,
|
|
34
|
+
recorded_at: recordedAt,
|
|
35
|
+
started_identity: raw.started_identity,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function utcRecordedAt() {
|
|
40
|
+
return execFileSync('date', ['-u', '+%Y-%m-%dT%H:%M:%S.000Z'], { encoding: 'utf8' }).trim()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
|
44
|
+
if (isCli) {
|
|
45
|
+
const [project, name] = process.argv.slice(2)
|
|
46
|
+
if (!project || !name) {
|
|
47
|
+
process.stderr.write('usage: refresh-seat-identity.mjs <project> <name>\n')
|
|
48
|
+
process.exit(2)
|
|
49
|
+
}
|
|
50
|
+
let setup
|
|
51
|
+
try {
|
|
52
|
+
setup = JSON.parse(readFileSync(join(resolve(project), '.team', 'setup-state.json'), 'utf8'))
|
|
53
|
+
} catch (error) {
|
|
54
|
+
process.stderr.write(`SEAT_IDENTITY_STATE_UNREADABLE: ${error.message}\n`)
|
|
55
|
+
process.exit(2)
|
|
56
|
+
}
|
|
57
|
+
const api = `${setup.server_url.replace(/\/$/u, '')}/api/${encodeURIComponent(setup.room)}`
|
|
58
|
+
const memberResponse = await fetch(`${api}/members/${encodeURIComponent(name)}`)
|
|
59
|
+
if (!memberResponse.ok) {
|
|
60
|
+
process.stderr.write(`SEAT_IDENTITY_UNREADABLE: 台帳に ${name} が無い(HTTP ${memberResponse.status})\n`)
|
|
61
|
+
process.exit(2)
|
|
62
|
+
}
|
|
63
|
+
const raw = (await memberResponse.json()).member
|
|
64
|
+
let observed
|
|
65
|
+
try {
|
|
66
|
+
observed = observePidCommand(raw.pid)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
process.stderr.write(`${error.code || 'SEAT_IDENTITY_UNOBSERVABLE'}: ${error.message}\n`)
|
|
69
|
+
process.exit(2)
|
|
70
|
+
}
|
|
71
|
+
let next
|
|
72
|
+
try {
|
|
73
|
+
next = refreshSeatRecord(raw, observed, utcRecordedAt())
|
|
74
|
+
} catch (error) {
|
|
75
|
+
process.stderr.write(`${error.code}: ${error.message}\n`)
|
|
76
|
+
process.exit(2)
|
|
77
|
+
}
|
|
78
|
+
if (next.argv_digest === raw.argv_digest) {
|
|
79
|
+
process.stdout.write(`${JSON.stringify({ outcome: 'unchanged', name, pid: raw.pid, argv_digest: raw.argv_digest })}\n`)
|
|
80
|
+
process.exit(0)
|
|
81
|
+
}
|
|
82
|
+
// 書込は席別 credential 経由(秘密値を argv/env に載せない既存境界を使う)
|
|
83
|
+
const credentialHelper = join(dirname(fileURLToPath(import.meta.url)), 'seat-credential.mjs')
|
|
84
|
+
const run = promisify(execFile)
|
|
85
|
+
const credential = (await run('node', [credentialHelper, 'prepare', resolve(project), setup.room, name],
|
|
86
|
+
{ env: { ...process.env, PEERTABLE_POST_TOKEN: undefined } })).stdout.trim()
|
|
87
|
+
const body = JSON.stringify({ name, argv_digest: next.argv_digest, identity_recorded_at: next.recorded_at })
|
|
88
|
+
await run('node', [credentialHelper, 'request', credential, 'POST', `${api}/members`, body],
|
|
89
|
+
{ env: { ...process.env, PEERTABLE_POST_TOKEN: undefined } })
|
|
90
|
+
process.stdout.write(`${JSON.stringify({
|
|
91
|
+
outcome: 'updated',
|
|
92
|
+
name,
|
|
93
|
+
pid: next.pid,
|
|
94
|
+
argv_digest: next.argv_digest,
|
|
95
|
+
previous_argv_digest: raw.argv_digest,
|
|
96
|
+
})}\n`)
|
|
97
|
+
}
|
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// Peertable スクリプト群の OS プロセス観測ライブラリ(唯一の所有者)。
|
|
3
|
+
// pane_pid から Lattice attach 用の pid / lstart / argv を1件に決める CLI を兼ねる。
|
|
3
4
|
// POSIX は ps。Windows は Win32_Process(ps -Ao は Git Bash で unknown option -- A)。
|
|
5
|
+
// ps の観測規約(LC_ALL=C・/bin/ps 絶対パス・trim)と argv digest の計算はこのファイルだけが持つ。
|
|
6
|
+
// 他スクリプトはここから import し、ps を手書きしない(locale 罠の再発防止。caveat:
|
|
7
|
+
// ps-locale-lstart-lc-time-ascii-argv-lc-ctype-digest)。
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
4
9
|
import { execFileSync } from 'node:child_process'
|
|
5
10
|
import { resolve } from 'node:path'
|
|
6
11
|
import { fileURLToPath } from 'node:url'
|
|
7
12
|
|
|
13
|
+
export function hashArgv(argv) {
|
|
14
|
+
return createHash('sha256').update(String(argv), 'utf8').digest('hex')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 記録済み pid そのものの lstart / argv を観測する(pane からの席特定はしない)。
|
|
18
|
+
// refresh-seat-identity・doctor が使う。
|
|
19
|
+
export function observePidCommand(pid) {
|
|
20
|
+
if (!Number.isSafeInteger(pid) || pid < 1) {
|
|
21
|
+
const error = new Error('pid が正整数でない')
|
|
22
|
+
error.code = 'SEAT_IDENTITY_NO_PID'
|
|
23
|
+
throw error
|
|
24
|
+
}
|
|
25
|
+
const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', env: psEnv() }).trim()
|
|
26
|
+
const argv = execFileSync('/bin/ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', env: psEnv() }).trim()
|
|
27
|
+
if (!started || !argv) {
|
|
28
|
+
const error = new Error('pid の lstart/command を観測できない')
|
|
29
|
+
error.code = 'SEAT_IDENTITY_UNOBSERVABLE'
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
return { pid, started_identity: started, argv, argv_digest: hashArgv(argv) }
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
const AGENT = /(?:claude|codex|grok|composer)(?:\.cmd|\.exe)?(?:\s|$)/iu
|
|
9
36
|
|
|
10
37
|
export function parseWinCreationDate(value) {
|
|
@@ -25,8 +52,12 @@ export function parseWinCreationDate(value) {
|
|
|
25
52
|
return parsed.toISOString()
|
|
26
53
|
}
|
|
27
54
|
|
|
55
|
+
// ps の lstart 書式(LC_TIME)と非ASCII argv のエスケープ(LC_CTYPE)は locale で変わり、
|
|
56
|
+
// 観測者ごとに digest が割れる(2026-08-22 実測)。Lattice 側の観測も LC_ALL=C に固定している。
|
|
57
|
+
const psEnv = () => ({ ...process.env, LC_ALL: 'C' })
|
|
58
|
+
|
|
28
59
|
function posixIdentity(pid) {
|
|
29
|
-
const table = execFileSync('ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8' })
|
|
60
|
+
const table = execFileSync('/bin/ps', ['-Ao', 'pid=,ppid=,pgid='], { encoding: 'utf8', env: psEnv() })
|
|
30
61
|
const leaders = table.split('\n').flatMap((line) => {
|
|
31
62
|
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/u.exec(line)
|
|
32
63
|
if (!match) return []
|
|
@@ -34,10 +65,10 @@ function posixIdentity(pid) {
|
|
|
34
65
|
return ppid === String(pid) && child === pgid ? [child] : []
|
|
35
66
|
})
|
|
36
67
|
const chosen = leaders.length === 1 ? leaders[0] : String(pid)
|
|
37
|
-
const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', chosen], { encoding: 'utf8' }).trim()
|
|
38
|
-
const argv = execFileSync('/bin/ps', ['-o', '
|
|
68
|
+
const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', chosen], { encoding: 'utf8', env: psEnv() }).trim()
|
|
69
|
+
const argv = execFileSync('/bin/ps', ['-o', 'command=', '-p', chosen], { encoding: 'utf8', env: psEnv() }).trim()
|
|
39
70
|
if (!started || !argv) throw new Error('pid の lstart/args を観測できない')
|
|
40
|
-
return { pid: Number(chosen), started_identity: started, argv }
|
|
71
|
+
return { pid: Number(chosen), started_identity: started, argv, argv_digest: hashArgv(argv) }
|
|
41
72
|
}
|
|
42
73
|
|
|
43
74
|
function winQuery(filter) {
|
|
@@ -60,7 +91,7 @@ function winIdentity(pid) {
|
|
|
60
91
|
const started = chosen.CreationDate ? parseWinCreationDate(chosen.CreationDate) : ''
|
|
61
92
|
const argv = String(chosen.CommandLine || '').trim()
|
|
62
93
|
if (!started || !argv) throw new Error('pid の CreationDate/CommandLine を観測できない')
|
|
63
|
-
return { pid: Number(chosen.ProcessId), started_identity: started, argv }
|
|
94
|
+
return { pid: Number(chosen.ProcessId), started_identity: started, argv, argv_digest: hashArgv(argv) }
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
|
@@ -146,6 +146,39 @@ async function serverKeepsStatus() {
|
|
|
146
146
|
return supportsMemberObservation(await res.json())
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// ---- 継続番犬(2026-08-22 オーナー設計)----
|
|
150
|
+
// 「AI は ToDo が終わっていなくてもターンを終えてしまう」への機械保証。busy が一定時間
|
|
151
|
+
// 続いた席が停止宣言([待機]/[監査提出]等)なしに idle 化したら、継続指示を配達する。
|
|
152
|
+
// 自己DM(席の自己規律)は保険として残るが、継続の保証はこの番犬が持つ。
|
|
153
|
+
// busy 2分未満は情報読取だけの正当な無宣言ターンがあるため起こさない(誤爆の代償は
|
|
154
|
+
// 待機宣言1ターンで、宣言後の episode は declared 判定で再起こしされない)。
|
|
155
|
+
const STOP_DECLARATION = /\[待機\]|\[監査提出\]|待機します|散会/u
|
|
156
|
+
const NUDGE_MIN_BUSY_MS = 120_000
|
|
157
|
+
const nudgedEpisodes = new Map() // name -> busySince(同一エピソード1回だけ)
|
|
158
|
+
async function nudgeIfDropped(name, busySince) {
|
|
159
|
+
if (!busySince || nudgedEpisodes.get(name) === busySince) return
|
|
160
|
+
if (Date.now() - Date.parse(busySince) < NUDGE_MIN_BUSY_MS) return
|
|
161
|
+
nudgedEpisodes.set(name, busySince)
|
|
162
|
+
let messages
|
|
163
|
+
try {
|
|
164
|
+
messages = (await (await fetch(`${url}/api/${encodeURIComponent(room)}/messages`)).json()).messages ?? []
|
|
165
|
+
} catch { return }
|
|
166
|
+
const since = Date.parse(busySince)
|
|
167
|
+
if (messages.some(m => m.from === name && Date.parse(m.ts) >= since && STOP_DECLARATION.test(m.body ?? ''))) return
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) },
|
|
172
|
+
body: JSON.stringify({ from: 'alarm', to: name,
|
|
173
|
+
body: '[継続] 停止宣言なしにターンが終了した。未完の作業があれば続行すること。手番が無いなら規約どおり [待機] を宣言してから沈黙すること。' }),
|
|
174
|
+
})
|
|
175
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
176
|
+
console.error(`seat-status-bridge: 継続番犬が ${name} を起こした(停止宣言なしの busy→idle)`)
|
|
177
|
+
} catch (e) {
|
|
178
|
+
console.error(`seat-status-bridge: 継続番犬の起こしに失敗: ${e.message}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
149
182
|
const last = new Map() // name -> { status, at }
|
|
150
183
|
let supported = null // server が status を保持する版か(未判定は null)
|
|
151
184
|
const tokenBucket = value => value === null ? null : Math.floor(value / 1_000)
|
|
@@ -221,6 +254,7 @@ async function tick() {
|
|
|
221
254
|
await send(name, observation, observedAt)
|
|
222
255
|
last.set(name, { ...observation, at: now })
|
|
223
256
|
sent++
|
|
257
|
+
if (prev?.status === 'busy' && observation.status === 'idle') await nudgeIfDropped(name, prev.busySince)
|
|
224
258
|
if (changed) console.error(`seat-status-bridge: ${name} → ${observation.status}${prev ? `(${prev.status} から)` : ''}`)
|
|
225
259
|
} catch (e) {
|
|
226
260
|
failed++
|
|
@@ -189,6 +189,11 @@ export const BLOCKED_MARKERS = [
|
|
|
189
189
|
'1. I am using this for local development',
|
|
190
190
|
'1. Yes, continue',
|
|
191
191
|
'Do you want to proceed?',
|
|
192
|
+
// Codex v0.148 のコマンド承認ダイアログ(2026-08-22 実測: 承認待ちの席が idle と
|
|
193
|
+
// 誤表示され、オーナーが「動いているのに表示が変わらない」と気づいた)
|
|
194
|
+
'Would you like to run the following command?',
|
|
195
|
+
'1. Yes, proceed',
|
|
196
|
+
'Press enter to confirm or esc to cancel',
|
|
192
197
|
]
|
|
193
198
|
|
|
194
199
|
/** Grok TUI の SpaceXAI coding-data バナー(2026-08-21 実測。入力は通るが席が死んだように見える)。 */
|
package/skill/scripts/setup.sh
CHANGED
|
@@ -119,6 +119,10 @@ else
|
|
|
119
119
|
cp "$tpl/done.sh" "$tdir/scripts/done.sh" && chmod +x "$tdir/scripts/done.sh"
|
|
120
120
|
cp "$tpl/independence-refresh.sh" "$tdir/scripts/independence-refresh.sh" && chmod +x "$tdir/scripts/independence-refresh.sh"
|
|
121
121
|
fi
|
|
122
|
+
# 目覚まし係の登録口は全モード共通で配る(member.md の待機作法が参照する)
|
|
123
|
+
cp "$repo/skill/scripts/alarm-set.sh" "$tdir/scripts/alarm-set.sh" && chmod +x "$tdir/scripts/alarm-set.sh"
|
|
124
|
+
# member.md が attach 手順で参照する。配り漏れると席が attach できず停止する(実被弾 2026-08-22)
|
|
125
|
+
cp "$repo/skill/scripts/pull-attach-input.mjs" "$tdir/scripts/pull-attach-input.mjs" && chmod +x "$tdir/scripts/pull-attach-input.mjs"
|
|
122
126
|
# room MCP 定義は project root の .mcp.json が正(channels は --mcp-config を解決しない。決定44)
|
|
123
127
|
added_root_mcp=false
|
|
124
128
|
if [ -f "$proj/.mcp.json" ]; then
|
|
@@ -197,6 +201,7 @@ printf '{"room":"%s","server_url":"%s","public_url":"%s","mode":"%s","plan_key":
|
|
|
197
201
|
# 4時間 HTTP 403 を撃ち続け、参加者一覧には点が1つも出なかった=起こしていないのと見分けがつかない)。
|
|
198
202
|
# AI は使わず席へも1バイト送らないので卓の作業を邪魔しない。**書けない時は常駐せずに死ぬ**ので、
|
|
199
203
|
# ここで壊れた常駐が黙って残ることは無い。setup-state.json を読むので、必ずその後で起こす。
|
|
204
|
+
"$repo/skill/scripts/ensure-bridge.sh" "$proj" alarm || echo "alarm-bridge の起動確認に失敗した(席は使える・目覚まし係だけ後で ensure-bridge.sh で立てる)" >&2
|
|
200
205
|
if "$repo/skill/scripts/ensure-bridge.sh" "$proj" seat-status; then
|
|
201
206
|
echo "seat-status-bridge: ready_at を確認した(ログ $tdir/seat-status-bridge.log・停止は teardown が行う)"
|
|
202
207
|
else
|
|
@@ -114,16 +114,18 @@ else
|
|
|
114
114
|
skip "seat-status-bridge(起動記録なし)"
|
|
115
115
|
fi
|
|
116
116
|
|
|
117
|
-
# run
|
|
118
|
-
# 止め残すと、卓が無いのに run の進行と介入を投稿し続ける常駐が残る
|
|
117
|
+
# run-bridge は退役済み(2026-08-22)。旧卓の常駐が残っていれば記録の pid を照合して止める。
|
|
119
118
|
if [ -f "$proj/.team/run-bridge.json" ]; then
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
rb_pid=$(python3 -c "import json;print(json.load(open('$proj/.team/run-bridge.json')).get('pid',''))" 2>/dev/null || true)
|
|
120
|
+
if [ -n "$rb_pid" ] && LC_ALL=C /bin/ps -o command= -p "$rb_pid" 2>/dev/null | grep -q "run-bridge.mjs"; then
|
|
121
|
+
kill "$rb_pid" 2>/dev/null || true
|
|
122
|
+
did "run-bridge(退役済み・旧常駐 pid ${rb_pid} を停止)"
|
|
122
123
|
else
|
|
123
|
-
|
|
124
|
+
skip "run-bridge(退役済み・生きた常駐なし)"
|
|
124
125
|
fi
|
|
126
|
+
rm -f "$proj/.team/run-bridge.json"
|
|
125
127
|
else
|
|
126
|
-
skip "run-bridge
|
|
128
|
+
skip "run-bridge(退役済み)"
|
|
127
129
|
fi
|
|
128
130
|
|
|
129
131
|
# run の close は成果が既定branchへ着地した証拠ではない。bridgeを止めてから、`run list`が挙げる
|
|
@@ -17,6 +17,7 @@ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from
|
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { promisify } from 'node:util'
|
|
19
19
|
import { resolveSeatObservation, tmuxArgv } from './seat-usage.mjs'
|
|
20
|
+
import { keysForCodexPane } from './codex-dialog.mjs'
|
|
20
21
|
import { BROADCAST_RECIPIENT, formatWakeNotice, isIdleSelfWake, isWakeupBridgeTarget, shouldDeferGrokWake } from './wakeup-delivery.mjs'
|
|
21
22
|
import { bridgeRecordLive } from './bridge-record-live.mjs'
|
|
22
23
|
|
|
@@ -271,9 +272,56 @@ async function wake(seat, msgs) {
|
|
|
271
272
|
error.code = code
|
|
272
273
|
throw error
|
|
273
274
|
}
|
|
275
|
+
// **agent CLI が死んで pane が素の shell に戻っていたら、1バイトも打たない。**
|
|
276
|
+
// shell へ room の本文を send-keys すると、本文がそのまま shell コマンドとして実行される
|
|
277
|
+
// (実被弾 2026-08-22: codex 終了後の bash へ配達され `command not found` が走った。
|
|
278
|
+
// 本文次第では席の権限で任意コマンドになる)。配達は止め、毎周期 typed log で叫ぶ。
|
|
279
|
+
// **pane_current_command は「bash の下で agent CLI が走る」形を bash と報告することがある**
|
|
280
|
+
// (実被弾 2026-08-22: 生きている codex 席が bash 判定になり配達が全停止、席が起きなかった)。
|
|
281
|
+
// shell 名だった時だけ pane 配下の生きた子孫を数え、**子孫ゼロの素の shell** だけを遮断する。
|
|
282
|
+
// agent CLI が死ねば子孫も消えるので、塞ぎたい穴(room 本文の shell 実行)はこの条件で塞がる。
|
|
283
|
+
const fg = await run('tmux', tmuxArgv(['display-message', '-p', '-t', observation.target, '#{pane_current_command}'], { socket: observation.socket }))
|
|
284
|
+
const fgCommand = String(fg.stdout ?? '').trim()
|
|
285
|
+
if (['bash', 'zsh', 'sh', 'dash', 'fish', 'tcsh', 'csh', 'ksh'].includes(fgCommand)) {
|
|
286
|
+
const panePidOut = await run('tmux', tmuxArgv(['display-message', '-p', '-t', observation.target, '#{pane_pid}'], { socket: observation.socket }))
|
|
287
|
+
const panePid = Number(String(panePidOut.stdout ?? '').trim())
|
|
288
|
+
let descendants = 0
|
|
289
|
+
if (Number.isSafeInteger(panePid) && panePid > 0) {
|
|
290
|
+
const psOut = await run('/bin/ps', ['-axo', 'pid=,ppid='], { env: { ...process.env, LC_ALL: 'C' } })
|
|
291
|
+
const children = new Map()
|
|
292
|
+
for (const line of String(psOut.stdout ?? '').split('\n')) {
|
|
293
|
+
const [pid, ppid] = line.trim().split(/\s+/u).map(Number)
|
|
294
|
+
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(ppid)) continue
|
|
295
|
+
if (!children.has(ppid)) children.set(ppid, [])
|
|
296
|
+
children.get(ppid).push(pid)
|
|
297
|
+
}
|
|
298
|
+
const queue = [...(children.get(panePid) ?? [])]
|
|
299
|
+
while (queue.length) {
|
|
300
|
+
const pid = queue.pop()
|
|
301
|
+
descendants += 1
|
|
302
|
+
queue.push(...(children.get(pid) ?? []))
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (descendants === 0) {
|
|
306
|
+
log(`SEAT_TUI_GONE: ${seat} の pane は子孫プロセスの無い素の shell(${fgCommand})=agent CLI が終了済み。`
|
|
307
|
+
+ 'shell へのコマンド実行を防ぐため配達しない。席を立て直すか leave-seat で畳むこと')
|
|
308
|
+
return 'deferred'
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
|
|
312
|
+
const screen = String(pane.stdout ?? '')
|
|
313
|
+
if (member.vendor === 'codex') {
|
|
314
|
+
const dialog = keysForCodexPane(screen)
|
|
315
|
+
if (dialog) {
|
|
316
|
+
for (const key of dialog.keys) {
|
|
317
|
+
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, key], { socket: observation.socket }))
|
|
318
|
+
}
|
|
319
|
+
log(`Codex ${dialog.kind} を通した: ${seat}`)
|
|
320
|
+
return 'deferred'
|
|
321
|
+
}
|
|
322
|
+
}
|
|
274
323
|
if (member.vendor === 'grok') {
|
|
275
|
-
const
|
|
276
|
-
const tail = String(pane.stdout).split('\n').slice(-14).join('\n')
|
|
324
|
+
const tail = screen.split('\n').slice(-14).join('\n')
|
|
277
325
|
if (shouldDeferGrokWake(member.vendor, tail)) {
|
|
278
326
|
if (!deferredBusy.has(seat)) {
|
|
279
327
|
log(`Grok席が実行中なのでidleまで待つ: ${seat} ← ${msgs.length} 件`)
|
|
@@ -291,6 +339,47 @@ async function wake(seat, msgs) {
|
|
|
291
339
|
await run('tmux', tmuxArgv(['send-keys', '-l', '-t', observation.target, text], { socket: observation.socket }))
|
|
292
340
|
await sleep(750)
|
|
293
341
|
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
|
|
342
|
+
// 送信検証。Codex v0.148 は長文入力後に「Create a plan?」popup が出て Enter を呑むことがあり、
|
|
343
|
+
// 本文が入力欄に残ったまま席が沈黙する(2026-08-22 実測: 2席が無音停止し、人が画面を見るまで
|
|
344
|
+
// 誰も気づけなかった)。打鍵成功=配達成功とみなさず、画面で送信成立を確認する。
|
|
345
|
+
// 残っていれば Escape(popup解除)→ Enter で再送信し、それでも残るなら受領を確定せず
|
|
346
|
+
// 次周期に再試行する(毎回ログに残るので沈黙しない)。
|
|
347
|
+
const markerSource = text.replace(/\s+/gu, '')
|
|
348
|
+
const marker = markerSource.slice(-24)
|
|
349
|
+
// Claude Code は長文入力を折り畳んで本文を画面から消し("paste again to expand" /
|
|
350
|
+
// "[Pasted text")、ターン実行中は "esc to interrupt" でなくスピナー(✻✽·)を出す。
|
|
351
|
+
// この2つで旧検証は「未送信」と誤判定し、しかも回復に撃つ Escape が **Claude の実行中
|
|
352
|
+
// ターンを中断**していた(実被弾 2026-08-22: 監査席が2秒ごとに中断され、監査が進まなかった)。
|
|
353
|
+
// Claude 席は「受理された兆候」を成功とみなし、回復キーに Escape を使わない。
|
|
354
|
+
const CLAUDE_ACCEPTED = /paste again to expand|\[Pasted text|esc to interrupt|✻|✽|✳|·\s*\w+ing…/u
|
|
355
|
+
const isClaude = member.vendor === 'claude'
|
|
356
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
357
|
+
await sleep(1200)
|
|
358
|
+
const after = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
|
|
359
|
+
const tailLines = String(after.stdout ?? '').split('\n').slice(-14)
|
|
360
|
+
const tailJoined = tailLines.join(' ')
|
|
361
|
+
// 折返しはcapture上で行分割されるだけで空白は挟まらないため、空白除去で復元して照合する
|
|
362
|
+
const tailSquashed = tailLines.join('').replace(/\s+/gu, '')
|
|
363
|
+
const queuedOrRunning = tailJoined.includes('esc to interrupt') || tailJoined.includes('to queue message')
|
|
364
|
+
|| (isClaude && CLAUDE_ACCEPTED.test(tailJoined))
|
|
365
|
+
const stuck = (!isClaude && tailJoined.includes('esc dismiss'))
|
|
366
|
+
|| (marker.length >= 8 && tailSquashed.includes(marker) && !queuedOrRunning)
|
|
367
|
+
if (!stuck) break
|
|
368
|
+
if (attempt === 2) {
|
|
369
|
+
log(`DELIVERY_STUCK: ${seat} の入力欄に本文が残ったまま送信できない(popup/入力詰まり)。受領を確定せず次周期に再試行する`)
|
|
370
|
+
return 'deferred'
|
|
371
|
+
}
|
|
372
|
+
if (isClaude) {
|
|
373
|
+
// Escape は撃たない(実行中ターンを殺す)。Enter だけ打ち直す
|
|
374
|
+
log(`配達が送信されていない: ${seat}(Claude 席のため Escape は使わず Enter を打ち直す)`)
|
|
375
|
+
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
|
|
376
|
+
continue
|
|
377
|
+
}
|
|
378
|
+
log(`配達が送信されていない(popup/入力詰まり): ${seat}。Escape→Enter で再送信する`)
|
|
379
|
+
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Escape'], { socket: observation.socket }))
|
|
380
|
+
await sleep(300)
|
|
381
|
+
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
|
|
382
|
+
}
|
|
294
383
|
log(`TUIへ入れた: ${seat} ← ${msgs.length} 件(最新 seq ${last.seq})`)
|
|
295
384
|
}
|
|
296
385
|
|
|
@@ -36,7 +36,7 @@ export function isWakeupBridgeTarget(member, options = {}) {
|
|
|
36
36
|
&& observe.tmux_target,
|
|
37
37
|
)
|
|
38
38
|
if (hasPane) return true
|
|
39
|
-
const vendor = member.vendor
|
|
39
|
+
const vendor = member.vendor
|
|
40
40
|
return vendor === 'codex' || vendor === 'grok'
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
## 作業ループ
|
|
14
14
|
|
|
15
15
|
**次にやる仕事があるターンを終える直前に `post(to: "<自分の名前>", message: "[次の行動] ...")` を1回送れ。** この自己DMは席の TUI へ次ターンの入力として入る。仕事があるのに出さないと席は止まる。空の終了通知は使うな。
|
|
16
|
-
**手番が無く待機に入るときは `[次の行動]` 自己DMを出すな。** 親へ `[待機]`
|
|
16
|
+
**手番が無く待機に入るときは `[次の行動]` 自己DMを出すな。** 親へ `[待機]` を一度だけ送り、沈黙する。再開は inbound だけ。待機自己DMは自分を起こし無限ループになる(2026-08-20 実測)。
|
|
17
|
+
**解放条件のある待機は、寝る前に目覚まし係へ条件を登録する(2026-08-22 オーナー設計)。** `"$(dirname "$0")"` 相当の `.team/scripts/alarm-set.sh <project> <自分の名前> "<何の条件か>" "<exit 0 で成立になる判定コマンド>"` を実行してから待機する。条件が成立すると `[待機解放条件成立]` が届いて起きる。**他席への「終わったら教えて」という口約束に解放を依存させない**(相手の死・退席・レース負けで永眠する)。**結果待ちのポーリングに自己DMやターンを使わない**——判定は目覚まし係の shell が無償で回す。
|
|
18
|
+
**停止宣言なしにターンを終えると継続番犬に起こされる。** busy が2分以上続いた席が `[待機]`/`[監査提出]` を出さずに idle 化すると `[継続]` が届く。作業を落としたなら続行し、手番が無いなら待機を宣言し直す。
|
|
17
19
|
|
|
18
20
|
**作業を選ぶのも始めるのもあなたである。** 装置から仕事が降ってくることはないし、着手前に装置の許可を待つこともない(オーナー裁定 2026-08-09・改・裁定1)。Lattice が居る卓では、着手した後に装置が競合を見て介入してくることがある——それは次の「装置が介入してきた時」に従う。
|
|
19
21
|
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
5. 証跡ファイル `evidence/{{PLAN_KEY}}/<task_id>.md` に、最終的な試験内容と試験結果を含めて「何を作り、どう確認したか」を書き、変更ファイルと証跡だけをcommitする
|
|
27
29
|
6. その証跡と同じ最終試験内容・結果を監査担当へ渡す。作業者自身は `.team/scripts/done.sh` や `lattice todo done` を実行しない
|
|
28
30
|
7. 監査担当として結果を受け取った場合は、提出された試験内容と試験結果が元PLAN・工程正本・受入条件に照らして妥当か判断する。試験を再実行せず、個人の思想や計画外の改善を完了条件へ加えない
|
|
29
|
-
8. 妥当なら監査担当が `.team/scripts/done.sh <task_id> --plan <plan_key>`
|
|
31
|
+
8. 妥当なら監査担当が `.team/scripts/done.sh <task_id> --plan <plan_key>` で工程をクローズする。クローズに親・オーナーの裁定は要らず、待たない——親が卓上で別の手順を言っていても、監査担当のクローズ権限が優先する。親は campaign 終端の最終監査で全量を見る(オーナー裁定 2026-08-22)。未着地の feat は `done.sh` が canonical main へ merge して push する。親へ着地を依頼しない。`done.sh` は証跡と同じ本文を Lattice の `test_result` へ記録し、remaining の並列記録も更新してから戻る。done を読返してから `post(to: "all", message: "次の工程に着手してください")` とだけ指示し、具体的な次工程は指示しない
|
|
30
32
|
9. 不合格なら、現在モデルでの修正機会は1回だけとする。再び不合格になったら親へmodel変更を依頼し、`Luna → Terra → Sol`の順で一段昇格する。自分で席設定を変えない
|
|
31
33
|
10. 作業者は監査担当によるクローズを確認し、工程正本から次のreadyを選ぶ
|
|
32
34
|
11. **claimできるToDoが無いなら仕事を発明しない(決定68)。** 依頼されていない監査・他席への状況照会・正典の自主レビューを暇つぶしに始めない。縮退の打診が来たらWIP棚卸しを正直に返す
|
|
@@ -85,17 +87,20 @@ lattice run intake --run .lattice/runs/<run-id> --task <id>
|
|
|
85
87
|
空のまま**である。作った席が他の席の仕事を決めたことにはならない。
|
|
86
88
|
|
|
87
89
|
1. **`todo start` を先に済ませてから intake する。** 装置は Todo 正本の start event へ束縛するので、start していない task は intake できない。**逆順にしない。**
|
|
88
|
-
2. **`intervention.state` を読む。** `none` なら worktree を使ってそのまま進める。`hold` の理由が `record_stale` または `artifact_binding_mismatch`(並列記録の失効・再 compile
|
|
90
|
+
2. **`intervention.state` を読む。** `none` なら worktree を使ってそのまま進める。`hold` の理由が `record_stale` または `artifact_binding_mismatch`(並列記録の失効・再 compile との交差)なら、未受理 intake を解放し、canonical で `.team/scripts/independence-refresh.sh {{PLAN_KEY}}` を通してから intake を打ち直す。他の着手済み task と同じ書き込み資源でぶつかった hold は、**担当 ToDo を手放す理由にならず、退席の理由にもならない**(旧規約の「hold=退席」は 2026-08-22 に廃止。卓を空にしただけだった)。未受理 intake を解放し、競合相手の task と理由を room へ一度記録して、その task のクローズを待つ。待つ間に自分の別の active WIP があればそれを進める。解消は `lattice run intake intervention --run <ref> --task <id>` の読み直しで確認してから intake を打ち直す。**hold を無視して進めない。**
|
|
89
91
|
3. **worktree を受け取ったら、自分の pid を装置へ渡す(attach)。** これをしないと、装置は競合時に「留まれ」と言うことはできても、実際に止めることができない(協調 hold のまま)。
|
|
90
92
|
```
|
|
91
93
|
lattice run intake attach --run <ref> --task <id> --input <file>
|
|
92
94
|
```
|
|
93
|
-
input は **`.team/
|
|
95
|
+
input は **`.team/scripts/pull-attach-input.mjs <project> <あなたの名前>` が room 台帳(member 行の本人性欄)から作る**(席file は 2026-08-22 廃止・変換も再計算も要らない)。
|
|
94
96
|
```json
|
|
95
97
|
{"schema":"lattice.pull_worker_attach_input.v1","name":…,"session":…,"pid":…,
|
|
96
98
|
"started_identity":…,"argv_digest":…,"recorded_at":…}
|
|
97
99
|
```
|
|
98
|
-
**pid を自分で推定しない。**
|
|
100
|
+
**pid を自分で推定しない。** 台帳の member 行だけが正で、無ければ room で言う(黙って別の値を渡さない)。**他の席の本人性欄を使わない。**
|
|
101
|
+
**argv_digest も席が計算し直さない。** pid と lstart が台帳と一致するのに digest だけ live と違うときは実行層死亡ではない。room で親へ「digest 更新が要る」とだけ言い、親が `refresh-seat-identity.mjs` で Lattice と同じ `/bin/ps -o command=` 観測へ揃える。
|
|
102
|
+
`lattice run intake` 自体は pid を取らない。attach の前に CLI の `process.pid` や死んだ子 pid を渡すと `ADAPTER_CONTROLLER_UNAVAILABLE: process start identity観測失敗` になる。それは実行層の死亡ではない。台帳の pid で打ち直す。
|
|
103
|
+
pull run の `driver_state=stopped` かつ `intakes=[]` は、まだ intake していない平常である。親へ「可視性を復旧して」と求めない。
|
|
99
104
|
4. **intake は1本ずつ。** 前の intake が accepted / closed / released になるまで次を取らない。**席は1つの process なので、2本 intake すると片方の hold がもう片方を巻き込む**——制御の粒度が壊れる。
|
|
100
105
|
5. **worktree の中だけを、絶対パスで触る。** `cd` しない・env を書き換えない・別 project へ移らない。席の room 接続と MCP 解決は cwd と env に乗っているので、動かすと卓から落ちる。git は `git -C <worktree> …`、編集は絶対パスで開く
|
|
101
106
|
6. **commit してよい。禁止は `push` / `branch` / `merge` / `rebase` / `reset` / `stash` の6つ**(正は Lattice engine の `FORBIDDEN_OPERATIONS`)。worktree は `base_sha` の detached HEAD なので、そこへ積む commit は base の子孫のままで canonical branch を動かさない。逆に6つは HEAD を base の子孫から外すか、外部へ効果を出す操作なので、観測の前提か公開契約のどちらかを壊す
|