peertable 0.5.0 → 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/package.json +1 -1
- package/room/client.mjs +1 -2
- package/skill/scripts/alarm-bridge.mjs +117 -0
- package/skill/scripts/alarm-set.sh +22 -0
- package/skill/scripts/ensure-bridge.sh +1 -1
- package/skill/scripts/launch-seat.sh +6 -2
- package/skill/scripts/seat-status-bridge.mjs +34 -0
- package/skill/scripts/setup.sh +5 -0
- package/skill/scripts/wakeup-bridge.mjs +43 -4
- package/skill/templates/member.md +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peertable",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
@@ -13,7 +13,7 @@ import { findModelsDoc, resolveSeatIdentity } from '../skill/scripts/resolve-sea
|
|
|
13
13
|
|
|
14
14
|
// client.mjs 側のハードコード版数。package.json の version と一致していることを
|
|
15
15
|
// diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
|
|
16
|
-
const MCP_VERSION = '0.5.
|
|
16
|
+
const MCP_VERSION = '0.5.1'
|
|
17
17
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
18
|
|
|
19
19
|
const USAGE = `usage:
|
|
@@ -209,7 +209,6 @@ const IDENTITY = Object.fromEntries(Object.entries({
|
|
|
209
209
|
effort: process.env.PEERTABLE_EFFORT,
|
|
210
210
|
roles,
|
|
211
211
|
mission: process.env.PEERTABLE_MISSION,
|
|
212
|
-
aiterm_session_id: process.env.AITERM_SESSION_ID,
|
|
213
212
|
observe,
|
|
214
213
|
}).filter(([, v]) => v != null && v !== '' && !(Array.isArray(v) && v.length === 0)))
|
|
215
214
|
{
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 目覚まし係(2026-08-22 オーナー設計)。AI ではない常駐スクリプト。
|
|
3
|
+
//
|
|
4
|
+
// 席は「待機からの解放条件を判定するスクリプト」を登録してから寝る(alarm-set.sh)。
|
|
5
|
+
// 本常駐が高頻度でそのスクリプトを回し、exit 0 になったら登録席へ
|
|
6
|
+
// 「[待機解放条件成立] <note>」の DM を room へ置く(配達は wakeup-bridge の正規経路)。
|
|
7
|
+
// 条件の約束が席の揮発文脈でなく装置に住むので、依頼相手の死・レース負け・宛先不在・
|
|
8
|
+
// 外部/時刻条件・相互待機のどれでも待機が永眠しない。
|
|
9
|
+
//
|
|
10
|
+
// usage: alarm-bridge.mjs <project_dir> 起動(nohup で常駐)
|
|
11
|
+
// alarm-bridge.mjs <project_dir> --stop 停止
|
|
12
|
+
//
|
|
13
|
+
// 登録: .team/alarms/<id>.json
|
|
14
|
+
// { "seat": "ren", "note": "転写job j42 完了", "script": "test -f ~/asr/jobs/j42/result.json",
|
|
15
|
+
// "interval_s": 10, "created_at": "..." }
|
|
16
|
+
// スクリプトは bash -c で実行される(timeout 30秒)。exit 0 = 条件成立。
|
|
17
|
+
// 成立したら DM を置いて登録 file を削除する。スクリプトのエラーは成立と区別してログへ出す。
|
|
18
|
+
import { execFile } from 'node:child_process'
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { promisify } from 'node:util'
|
|
22
|
+
|
|
23
|
+
import { resolvePostToken } from './seat-usage.mjs'
|
|
24
|
+
import { observePidCommand } from './seat-identity.mjs'
|
|
25
|
+
|
|
26
|
+
const run = promisify(execFile)
|
|
27
|
+
const [proj, flag] = process.argv.slice(2)
|
|
28
|
+
if (!proj) { console.error('usage: alarm-bridge.mjs <project_dir> [--stop]'); process.exit(1) }
|
|
29
|
+
|
|
30
|
+
const team = join(proj, '.team')
|
|
31
|
+
const alarmsDir = join(team, 'alarms')
|
|
32
|
+
const record = join(team, 'alarm-bridge.json')
|
|
33
|
+
const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
|
|
34
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
35
|
+
const log = line => console.error(`[${new Date().toISOString()}] ${line}`)
|
|
36
|
+
|
|
37
|
+
// 停止・再認証は run-bridge と同じ作法(pid+lstart 照合。pid 再利用先を殺さない)
|
|
38
|
+
function stopRecorded(strict) {
|
|
39
|
+
if (!existsSync(record)) { if (strict) log('起動記録が無い(既に停止)'); return }
|
|
40
|
+
const stored = JSON.parse(readFileSync(record, 'utf8'))
|
|
41
|
+
let facts = null
|
|
42
|
+
try { facts = observePidCommand(stored.pid) } catch { /* 観測不能=死亡扱い */ }
|
|
43
|
+
const same = facts && stored.start_identity !== undefined
|
|
44
|
+
&& facts.started_identity === stored.start_identity && facts.argv.includes('alarm-bridge.mjs')
|
|
45
|
+
if (!same) { unlinkSync(record); log(`死んだ記録を掃除した(pid ${stored.pid})`); return }
|
|
46
|
+
process.kill(stored.pid, 'SIGTERM')
|
|
47
|
+
log(`前の目覚まし係を停止した(pid ${stored.pid})`)
|
|
48
|
+
try { unlinkSync(record) } catch { /* 本人のcleanupと競合してよい */ }
|
|
49
|
+
}
|
|
50
|
+
stopRecorded(flag === '--stop')
|
|
51
|
+
if (flag === '--stop') process.exit(0)
|
|
52
|
+
|
|
53
|
+
const setup = JSON.parse(readFileSync(join(team, 'setup-state.json'), 'utf8'))
|
|
54
|
+
const { room, server_url: url } = setup
|
|
55
|
+
const token = resolvePostToken(process.env)
|
|
56
|
+
if (!token) { console.error('ALARM_BRIDGE_TOKEN_MISSING: 書込トークンが無い'); process.exit(1) }
|
|
57
|
+
mkdirSync(alarmsDir, { recursive: true })
|
|
58
|
+
|
|
59
|
+
const self = observePidCommand(process.pid)
|
|
60
|
+
writeFileSync(record, JSON.stringify({
|
|
61
|
+
pid: process.pid, start_identity: self.started_identity, room, server_url: url,
|
|
62
|
+
started_at: new Date().toISOString(), ready_at: new Date().toISOString(),
|
|
63
|
+
}) + '\n')
|
|
64
|
+
const cleanup = () => { try { unlinkSync(record) } catch {} process.exit(0) }
|
|
65
|
+
process.on('SIGTERM', cleanup)
|
|
66
|
+
process.on('SIGINT', cleanup)
|
|
67
|
+
|
|
68
|
+
async function wake(seat, note) {
|
|
69
|
+
const res = await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json', 'X-Peertable-Token': token },
|
|
72
|
+
body: JSON.stringify({ from: 'alarm', to: seat, body: `[待機解放条件成立] ${note}` }),
|
|
73
|
+
})
|
|
74
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const nextDue = new Map() // file -> epoch_ms
|
|
78
|
+
async function tick() {
|
|
79
|
+
let entries
|
|
80
|
+
try { entries = readdirSync(alarmsDir).filter(f => f.endsWith('.json')) } catch { return }
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const file = join(alarmsDir, entry)
|
|
83
|
+
const due = nextDue.get(file) ?? 0
|
|
84
|
+
if (Date.now() < due) continue
|
|
85
|
+
let reg
|
|
86
|
+
try { reg = JSON.parse(readFileSync(file, 'utf8')) } catch { continue } // 書込途中は次周へ
|
|
87
|
+
if (typeof reg.seat !== 'string' || typeof reg.script !== 'string' || !reg.seat || !reg.script) {
|
|
88
|
+
log(`ALARM_REGISTRATION_INVALID: ${entry}(seat/script が無い)を撤去する`)
|
|
89
|
+
try { unlinkSync(file) } catch {}
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
nextDue.set(file, Date.now() + Math.max(2, Number(reg.interval_s) || 10) * 1000)
|
|
93
|
+
let met = false
|
|
94
|
+
try {
|
|
95
|
+
await run('bash', ['-c', reg.script], { timeout: 30_000, maxBuffer: 1024 * 1024 })
|
|
96
|
+
met = true
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// exit 非0 = 条件未成立(正常)。timeout/spawn 失敗はログへ(成立と区別し、握りつぶさない)
|
|
99
|
+
if (e.killed || e.code === 'ENOENT') log(`条件スクリプトの実行失敗(${entry}): ${e.message}`)
|
|
100
|
+
}
|
|
101
|
+
if (!met) continue
|
|
102
|
+
try {
|
|
103
|
+
await wake(reg.seat, reg.note || entry)
|
|
104
|
+
unlinkSync(file)
|
|
105
|
+
nextDue.delete(file)
|
|
106
|
+
log(`起床: ${reg.seat} ← ${reg.note || entry}`)
|
|
107
|
+
} catch (e) {
|
|
108
|
+
log(`起床の投函に失敗(次周期に再試行): ${e.message}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
log(`alarm-bridge start: room=${room} project=${proj} pid=${process.pid}`)
|
|
114
|
+
for (;;) {
|
|
115
|
+
try { await tick() } catch (e) { log(`tick 失敗(続行): ${e.message}`) }
|
|
116
|
+
await sleep(2000)
|
|
117
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 目覚まし係へ待機解放条件を登録する(席が待機に入る前に使う)。
|
|
3
|
+
# usage: alarm-set.sh <project_dir> <seat> <note> <script...>
|
|
4
|
+
# script は bash -c で実行され、exit 0 で条件成立とみなされる。
|
|
5
|
+
# **script はシングルクォートで渡すこと**。ダブルクォートだと `$()` や変数が登録側の
|
|
6
|
+
# shell で先に展開され、判定コマンドでなく展開結果が登録される(かえで実測 2026-08-22)。
|
|
7
|
+
# 成立すると席へ「[待機解放条件成立] <note>」が配達され、登録は自動で消える。
|
|
8
|
+
set -eu
|
|
9
|
+
proj="${1:?project_dir}"; seat="${2:?seat}"; note="${3:?note}"; shift 3
|
|
10
|
+
script="$*"
|
|
11
|
+
[ -n "$script" ] || { echo "ALARM_SET_SCRIPT_REQUIRED: 条件スクリプトが空" >&2; exit 2; }
|
|
12
|
+
dir="$proj/.team/alarms"
|
|
13
|
+
mkdir -p "$dir"
|
|
14
|
+
id="$(date +%s)-$$"
|
|
15
|
+
python3 - "$dir/$id.json" "$seat" "$note" "$script" <<'PY'
|
|
16
|
+
import json, sys
|
|
17
|
+
out, seat, note, script = sys.argv[1:5]
|
|
18
|
+
with open(out, 'w') as f:
|
|
19
|
+
json.dump({'seat': seat, 'note': note, 'script': script, 'interval_s': 10,
|
|
20
|
+
'created_at': __import__('datetime').datetime.utcnow().isoformat() + 'Z'}, f, ensure_ascii=False)
|
|
21
|
+
PY
|
|
22
|
+
echo "ALARM_SET_OK: ${dir}/${id}.json(seat=${seat} note=${note})"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
set -euo pipefail
|
|
4
4
|
|
|
5
5
|
proj="$1"; name="$2"; shift 2
|
|
6
|
-
case "$name" in seat-status) script="seat-status-bridge.mjs" ;; wakeup) script="wakeup-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> [args...]" >&2; exit 1 ;; esac
|
|
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
7
|
team="$proj/.team"; record="$team/$name-bridge.json"; log="$team/$name-bridge.log"
|
|
8
8
|
force=false
|
|
9
9
|
if [ "${1:-}" = "--force" ]; then force=true; shift; fi
|
|
@@ -691,9 +691,11 @@ if [ -z "$seat_pid" ]; then
|
|
|
691
691
|
# 記録が無ければ席は attach できず、装置の介入は協調 hold のままになる。**黙らない。**
|
|
692
692
|
echo "seat identity を記録できなかった: ${sess} の process group leader を1つに確定できない(席は着席済み)" >&2
|
|
693
693
|
else
|
|
694
|
-
|
|
694
|
+
# aiterm_session_id の書き手はランチャー(launch receipt の所有者)。席の client は
|
|
695
|
+
# Codex closed-mode env で AITERM_SESSION_ID を受け取れないため、ここで台帳へ載せる。
|
|
696
|
+
ident_body=$(python3 - "$name" "$seat_ident" "$aiterm_session_id" <<'PY'
|
|
695
697
|
import json, subprocess, sys
|
|
696
|
-
name, ident_raw = sys.argv[1:
|
|
698
|
+
name, ident_raw, aiterm_session = sys.argv[1:4]
|
|
697
699
|
ident = json.loads(ident_raw)
|
|
698
700
|
if not ident.get('started_identity') or not ident.get('argv') or not ident.get('argv_digest'):
|
|
699
701
|
sys.exit('pid の lstart/args を観測できない')
|
|
@@ -702,6 +704,7 @@ print(json.dumps({
|
|
|
702
704
|
'pid': int(ident['pid']),
|
|
703
705
|
'started_identity': ident['started_identity'],
|
|
704
706
|
'argv_digest': ident['argv_digest'],
|
|
707
|
+
'aiterm_session_id': aiterm_session,
|
|
705
708
|
'identity_recorded_at': subprocess.run(['date', '-u', '+%Y-%m-%dT%H:%M:%S.000Z'],
|
|
706
709
|
capture_output=True, text=True, check=True).stdout.strip(),
|
|
707
710
|
}, ensure_ascii=False))
|
|
@@ -732,6 +735,7 @@ else
|
|
|
732
735
|
echo "SEAT_LEDGER_INCOMPLETE: 台帳の member 行に素性または本人性が欠けている(席は着席済み。doctor で確認)" >&2
|
|
733
736
|
fi
|
|
734
737
|
|
|
738
|
+
PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" alarm || echo "alarm-bridge の起動確認に失敗した(席は着席済み)" >&2
|
|
735
739
|
if PEERTABLE_CREDENTIAL_FILE="$credential_file" "$(dirname "$0")/ensure-bridge.sh" "$proj" seat-status; then
|
|
736
740
|
echo "seat-status-bridge: 起動確認済み"
|
|
737
741
|
else
|
|
@@ -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++
|
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
|
|
@@ -276,12 +276,37 @@ async function wake(seat, msgs) {
|
|
|
276
276
|
// shell へ room の本文を send-keys すると、本文がそのまま shell コマンドとして実行される
|
|
277
277
|
// (実被弾 2026-08-22: codex 終了後の bash へ配達され `command not found` が走った。
|
|
278
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 実行)はこの条件で塞がる。
|
|
279
283
|
const fg = await run('tmux', tmuxArgv(['display-message', '-p', '-t', observation.target, '#{pane_current_command}'], { socket: observation.socket }))
|
|
280
284
|
const fgCommand = String(fg.stdout ?? '').trim()
|
|
281
285
|
if (['bash', 'zsh', 'sh', 'dash', 'fish', 'tcsh', 'csh', 'ksh'].includes(fgCommand)) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
+
}
|
|
285
310
|
}
|
|
286
311
|
const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
|
|
287
312
|
const screen = String(pane.stdout ?? '')
|
|
@@ -321,6 +346,13 @@ async function wake(seat, msgs) {
|
|
|
321
346
|
// 次周期に再試行する(毎回ログに残るので沈黙しない)。
|
|
322
347
|
const markerSource = text.replace(/\s+/gu, '')
|
|
323
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'
|
|
324
356
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
325
357
|
await sleep(1200)
|
|
326
358
|
const after = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
|
|
@@ -329,13 +361,20 @@ async function wake(seat, msgs) {
|
|
|
329
361
|
// 折返しはcapture上で行分割されるだけで空白は挟まらないため、空白除去で復元して照合する
|
|
330
362
|
const tailSquashed = tailLines.join('').replace(/\s+/gu, '')
|
|
331
363
|
const queuedOrRunning = tailJoined.includes('esc to interrupt') || tailJoined.includes('to queue message')
|
|
332
|
-
|
|
364
|
+
|| (isClaude && CLAUDE_ACCEPTED.test(tailJoined))
|
|
365
|
+
const stuck = (!isClaude && tailJoined.includes('esc dismiss'))
|
|
333
366
|
|| (marker.length >= 8 && tailSquashed.includes(marker) && !queuedOrRunning)
|
|
334
367
|
if (!stuck) break
|
|
335
368
|
if (attempt === 2) {
|
|
336
369
|
log(`DELIVERY_STUCK: ${seat} の入力欄に本文が残ったまま送信できない(popup/入力詰まり)。受領を確定せず次周期に再試行する`)
|
|
337
370
|
return 'deferred'
|
|
338
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
|
+
}
|
|
339
378
|
log(`配達が送信されていない(popup/入力詰まり): ${seat}。Escape→Enter で再送信する`)
|
|
340
379
|
await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Escape'], { socket: observation.socket }))
|
|
341
380
|
await sleep(300)
|
|
@@ -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
|
|