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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.5.0",
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.0'
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
- ident_body=$(python3 - "$name" "$seat_ident" <<'PY'
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:3]
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++
@@ -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
- log(`SEAT_TUI_GONE: ${seat} pane shell(${fgCommand})に戻っている=agent CLI が終了済み。`
283
- + 'shell へのコマンド実行を防ぐため配達しない。席を立て直すか leave-seat で畳むこと')
284
- return 'deferred'
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
- const stuck = tailJoined.includes('esc dismiss')
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を出すな。** 親へ `[待機]` を一度だけ送り、沈黙する。再開は親または他人からの inbound だけ。待機自己DMは自分を起こし無限ループになる(2026-08-20 実測)。
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