peertable 0.8.51 → 0.8.53

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.md CHANGED
@@ -149,7 +149,7 @@ It interviews you, names the members, scaffolds `.team/` (charter + roles, isola
149
149
 
150
150
  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.
151
151
 
152
- The current npm release is **peertable 0.8.51**.
152
+ The current npm release is **peertable 0.8.53**.
153
153
 
154
154
  The current product contract is [docs/current-design.md](https://github.com/kitepon/peertable/blob/main/docs/current-design.md). Completed plans and the cumulative decision log are kept under `docs/archive/`; the current document map is [docs/00_overview.md](https://github.com/kitepon/peertable/blob/main/docs/00_overview.md).
155
155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peertable",
3
- "version": "0.8.51",
3
+ "version": "0.8.53",
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
@@ -14,7 +14,7 @@ import { boundedRecent, boundedUnread } from './message-bounds.mjs'
14
14
 
15
15
  // client.mjs 側のハードコード版数。package.json の version と一致していることを
16
16
  // diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
17
- const MCP_VERSION = '0.8.51'
17
+ const MCP_VERSION = '0.8.53'
18
18
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
19
19
 
20
20
  const USAGE = `usage:
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // wakeup-bridge の配達入口。席の TUI への打鍵は aiterm の公開 API(pty_send / agent_steer)だけで行う。
3
+ // PTY の状態(前面プロセス・termios・貼付方言・submit 成立)は aiterm が所有し、bridge は
4
+ // 「誰に・いつ・何を届けるか」と receipt だけを持つ(責務境界。2026-09-04 オーナー裁定)。
5
+ // usage: aiterm-deliver.mjs <session_id> <body_file> [--mode=dispatch|steer]
6
+ // dispatch: idle な席へ pty_send(自動 agent dispatch)。ready gate と submit 観測は aiterm。
7
+ // steer: 実行中ターンへ agent_steer(Codex 0.153 はキューへ積む)。idle なら delivery=idle が返る。
8
+ // stdout に receipt JSON を1行。失敗は stderr に aiterm のエラー本文を出して非ゼロ。
9
+ import { readFileSync } from 'node:fs'
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
11
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
12
+
13
+ const args = process.argv.slice(2)
14
+ const mode = (args.find(a => a.startsWith('--mode=')) ?? '--mode=dispatch').slice('--mode='.length)
15
+ const [sessionId, bodyFile] = args.filter(a => !a.startsWith('--'))
16
+ if (!sessionId || !bodyFile || !['dispatch', 'steer'].includes(mode)) {
17
+ process.stderr.write('usage: aiterm-deliver.mjs <session_id> <body_file> [--mode=dispatch|steer]\n')
18
+ process.exit(2)
19
+ }
20
+ const text = readFileSync(bodyFile, 'utf8')
21
+ const client = new Client({ name: 'peertable-wakeup-deliver', version: '0.8.53' })
22
+ await client.connect(new StdioClientTransport({ command: 'aiterm-mcp', env: process.env }))
23
+ try {
24
+ const result = mode === 'steer'
25
+ ? await client.callTool({ name: 'agent_steer', arguments: { session_id: sessionId, text } })
26
+ : await client.callTool({ name: 'pty_send', arguments: { session_id: sessionId, text, enter: true } })
27
+ if (result.isError) {
28
+ process.stderr.write(`${result.content?.[0]?.text ?? 'AITERM_DELIVER_FAILED'}\n`)
29
+ process.exit(1)
30
+ }
31
+ const receipt = result.structuredContent ?? JSON.parse(result.content?.[0]?.text ?? '{}')
32
+ process.stdout.write(`${JSON.stringify(receipt)}\n`)
33
+ } finally {
34
+ await client.close()
35
+ }
@@ -14,16 +14,16 @@
14
14
  // 今のターンへ混ざらず入力キューへ積まれ、次の user ターンになる。Grok 席だけ idle を待つ。
15
15
  import { execFile } from 'node:child_process'
16
16
  import {
17
- closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync,
17
+ existsSync, readFileSync, renameSync,
18
18
  unlinkSync, writeFileSync,
19
19
  } from 'node:fs'
20
20
  import { join } from 'node:path'
21
21
  import { promisify } from 'node:util'
22
- import { resolvePostToken, resolveSeatObservation, tmuxArgv } from './seat-usage.mjs'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { classifyPaneTail, resolvePostToken, resolveSeatObservation, tmuxArgv } from './seat-usage.mjs'
23
24
  import { keysForCodexPane } from './codex-dialog.mjs'
24
25
  import { BROADCAST_RECIPIENT, formatWakeNotice, isIdleSelfWake, isWakeupBridgeTarget, memberHarness, shouldDeferGrokWake } from './wakeup-delivery.mjs'
25
26
  import { bridgeRecordLive } from './bridge-record-live.mjs'
26
- import { seatPasteArgs } from './seat-input.mjs'
27
27
 
28
28
  const run = promisify(execFile)
29
29
  const [proj, ...rest] = process.argv.slice(2)
@@ -365,30 +365,6 @@ function advanceLastSeq() {
365
365
  if (advanced) saveDeliveryState()
366
366
  }
367
367
 
368
- // 「入力欄に本文が残っているか」の照合範囲。画面末尾全体で照合すると、配達済みの本文が
369
- // TUI の会話履歴として画面下部に表示され続ける形(Grok が常時、Codex も直後)を「残存」と
370
- // 誤検知し、成功した配達を failed 扱いで再試行し続ける(実被弾 2026-08-29: idle の Grok 席へ
371
- // 空 Enter を打ち続け、receipt が failed で固定された)。入力欄は画面の最下部に居るので、
372
- // **最後の composer マーカー行(Codex ›・Grok ❯)から末尾まで**だけを照合する。
373
- // マーカーが見えない画面では従来どおり末尾14行で照合する(狭めすぎて偽成功を作らない)。
374
- function composerSquashed(screen) {
375
- const lines = screen.split('\n')
376
- let start = -1
377
- for (let i = lines.length - 1; i >= 0; i--) {
378
- if (/[›❯]/u.test(lines[i])) { start = i; break }
379
- }
380
- if (start < 0) return lines.slice(-14).join('').replace(/\s+/gu, '')
381
- // マーカー行から**直後の空行まで**だけが composer。末尾まで含めると、Codex がターン間に
382
- // composer の下へ描く受理済み・キュー済みの本文(空行を挟んで表示される)を「残存」と
383
- // 誤検知する(実被弾 2026-08-29: 受理済み配達を failed 固定し、空 Enter を打ち続けた)。
384
- // 折返しの composer 続行行は空行を挟まず続くので、この区切りで取りこぼさない。
385
- let end = lines.length
386
- for (let i = start + 1; i < lines.length; i++) {
387
- if (lines[i].trim() === '') { end = i; break }
388
- }
389
- return lines.slice(start, end).join('').replace(/\s+/gu, '')
390
- }
391
-
392
368
  const deferredBusy = new Set()
393
369
  async function passKnownCodexDialog(member) {
394
370
  if (memberHarness(member) !== 'codex') return false
@@ -420,73 +396,17 @@ async function wake(seat, msgs) {
420
396
  error.code = code
421
397
  throw error
422
398
  }
423
- // **agent CLI が死んで pane が素の shell に戻っていたら、1バイトも打たない。**
424
- // shell room の本文を send-keys すると、本文がそのまま shell コマンドとして実行される
425
- // (実被弾 2026-08-22: codex 終了後の bash へ配達され `command not found` が走った。
426
- // 本文次第では席の権限で任意コマンドになる)。配達は止め、毎周期 typed log で叫ぶ。
427
- // **打鍵が届く先は pane tty の前面プロセスグループだけ**なので、配達可否はそれで判定する。
428
- // 「前面コマンド名がshellか」(実被弾 2026-08-22: bash 配下で生きる codex を誤遮断)でも、
429
- // 「pane 子孫に agent CLI が実在するか」(実被弾 2026-08-29: SIGTSTP で停止した codex は
430
- // 実在するが打鍵は bash が受け、room 本文がコマンド実行された)でも足りない。
431
- // ps の STAT `+` は tty の前面プロセスグループを OS が直接教える印であり、
432
- // 死亡・停止(T)・背面化のすべてを一つの条件で塞ぐ。
433
- const ttyOut = await run('tmux', tmuxArgv(['display-message', '-p', '-t', observation.target, '#{pane_tty}'], { socket: observation.socket }))
434
- const paneTty = String(ttyOut.stdout ?? '').trim().replace(/^\/dev\//u, '')
435
- const SHELLS = ['bash', 'zsh', 'sh', 'dash', 'fish', 'tcsh', 'csh', 'ksh']
436
- const ttyState = async () => {
437
- let foreground = false
438
- let stopped = false
439
- let agentPresent = false
440
- let label = '(不明)'
441
- if (paneTty) {
442
- const psOut = await run('/bin/ps', ['-t', paneTty, '-o', 'stat=,command='], { env: { ...process.env, LC_ALL: 'C' } })
443
- for (const line of String(psOut.stdout ?? '').split('\n')) {
444
- const m = line.trim().match(/^(\S+)\s+(.*)$/u)
445
- if (!m) continue
446
- const base = String(m[2]).split(/\s+/u, 1)[0].split('/').pop()
447
- if (SHELLS.includes(base)) {
448
- if (m[1].includes('+')) label = base
449
- continue
450
- }
451
- agentPresent = true
452
- if (m[1].includes('+')) foreground = true
453
- if (m[1].startsWith('T')) stopped = true
454
- }
455
- }
456
- return { foreground, stopped, agentPresent, label }
457
- }
458
- let tty = await ttyState()
459
- if (!tty.foreground && tty.agentPresent) {
460
- // Codex CLI の job control 欠陥(openai/codex#37088 系)で TUI が SIGTSTP/SIGTTIN 停止し、
461
- // 打鍵が shell へ落ちる形が反復した(実被弾 2026-08-29 に2席×複数回)。配達の前提を
462
- // 自動で回復する: pane の前面 shell へ `fg` を送って停止 job を前面へ戻し、成立を再観測する。
463
- // Codex 0.153 はツール実行後に tty の前面を bash へ返したまま **動き続ける**(STAT は T でなく S)
464
- // 形も出す(実測 2026-09-04: 3席が順に「codex 生存・bash 前面」になり、貼り付けが bash に落ちて
465
- // `^[[200~` が生で残り、SEAT_TUI_GONE 誤判定→自動蘇生が生きた席を立て直す連鎖になった)。
466
- // `fg` は停止 job にも背面で動く job にも効くので、agent が tty 上に実在する限り同じ復旧を使う。
467
- log(`SEAT_TUI_${tty.stopped ? 'STOPPED' : 'BACKGROUNDED'}: ${seat} の agent が${tty.stopped ? '停止(T)' : '背面(S)'}状態。fg で前面へ蘇生を試みる`)
468
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'C-u'], { socket: observation.socket }))
469
- await run('tmux', tmuxArgv(['send-keys', '-l', '-t', observation.target, 'fg'], { socket: observation.socket }))
470
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
471
- await sleep(2000)
472
- tty = await ttyState()
473
- if (tty.foreground) log(`SEAT_TUI_RESUMED: ${seat} の agent を前面へ復帰させた`)
474
- }
475
- if (!tty.foreground) {
476
- const foregroundLabel = tty.label
477
- log(`SEAT_TUI_GONE: ${seat} の pane tty 前面が agent CLI でない(前面: ${foregroundLabel})=`
478
- + '打鍵は shell に落ちる(agent 死亡・SIGTSTP 停止・背面化)。'
479
- + 'shell へのコマンド実行を防ぐため配達しない。席を立て直すか leave-seat で畳むこと')
480
- const error = new Error(`SEAT_TUI_GONE: ${seat}`)
481
- error.code = 'SEAT_TUI_GONE'
482
- throw error
483
- }
484
- if (await passKnownCodexDialog(member)) return 'deferred'
399
+ // **席の TUI への打鍵は aiterm の公開 API(pty_send / agent_steer)だけで行う。**
400
+ // PTY の状態(agent tty の前面か・termios raw か・貼付の方言・submit の成立)は aiterm が
401
+ // 所有し、bridge は「誰に・いつ・何を届けるか」と receipt だけを持つ(責務境界。2026-09-04
402
+ // オーナー裁定: 0.8.51/0.8.52 bridge に足した fg/stty は aiterm へ移し、ここからは撤去)。
403
+ // 死んだ席(agent 不在の素の shell)へは aiterm の ready gate が入力受付不能として送らない。
485
404
  const pane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
486
405
  const screen = String(pane.stdout ?? '')
487
- if (memberHarness(member) === 'grok') {
488
- const tail = screen.split('\n').slice(-14).join('\n')
489
- if (shouldDeferGrokWake(memberHarness(member), tail)) {
406
+ const harness = memberHarness(member)
407
+ const tail = screen.split('\n').slice(-14).join('\n')
408
+ if (harness === 'grok') {
409
+ if (shouldDeferGrokWake(harness, tail)) {
490
410
  if (!deferredBusy.has(seat)) {
491
411
  log(`Grok席が実行中なのでidleまで待つ: ${seat} ← ${msgs.length} 件`)
492
412
  deferredBusy.add(seat)
@@ -495,143 +415,54 @@ async function wake(seat, msgs) {
495
415
  }
496
416
  deferredBusy.delete(seat)
497
417
  }
498
- // Codexの入力欄は本文とEnterを同じtmux commandで送ると、初回turn完了後に
499
- // 本文が入力欄へ残ることがある。再試行時の半入力も含め、正規のsubmitを分離する。
500
- // 最後のEnterまで成功しない限りwakeは成功扱いにせず、flushSeatのreceiptも確定しない。
501
- // **前周期の本文が入力欄に残っている時は再打鍵しない**——C-u は複数行composerの1行しか
502
- // 消せず、周期ごとの全文再打鍵が本文を多重に蓄積させた(実被弾 2026-08-29: 約1時間の
503
- // STUCKループで入力欄が多重本文+制御文字で汚染され、配達不能が固定化した)。
504
- const preMarker = text.replace(/\s+/gu, '').slice(-24)
505
- const prePane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
506
- const preLoaded = preMarker.length >= 8
507
- && composerSquashed(String(prePane.stdout ?? '')).includes(preMarker)
508
- if (preLoaded) {
509
- log(`入力欄に前周期の本文が残存: ${seat}。再打鍵せず送信だけ再試行する`)
510
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
511
- } else {
512
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'C-u'], { socket: observation.socket }))
513
- await sleep(100)
514
- // 素打ち(send-keys -l)は長文で連続keystrokeとして届き、TUIのpaste burst検知を
515
- // 半端な状態に落として composer が submit 不能に固まる形を繰り返した(実被弾
516
- // 2026-08-29: Codex席が同日3回、掃除も効かない入力破損)。正規の bracketed paste
517
- // (load-buffer→paste-buffer -p)で「1回の貼り付け」として届ける。
518
- const pasteBuf = `wakeup-${process.pid}-${Date.now()}`
519
- const pasteFile = join(proj, '.team', `wakeup-paste-${process.pid}.txt`)
520
- writeFileSync(pasteFile, text)
521
- try {
522
- await run('tmux', tmuxArgv(['load-buffer', '-b', pasteBuf, pasteFile], { socket: observation.socket }))
523
- // 貼り方の方言(-pの要否等)は seat-input.mjs の方言表だけが知る。ここでは判断しない。
524
- await run('tmux', tmuxArgv(
525
- seatPasteArgs(memberHarness(member), pasteBuf, observation.target),
526
- { socket: observation.socket },
527
- ))
528
- } finally {
529
- try { unlinkSync(pasteFile) } catch { /* 一時fileの掃除失敗は配達判定に影響しない */ }
418
+ if (await passKnownCodexDialog(member)) return 'deferred'
419
+ const sessionId = typeof member.aiterm_session_id === 'string' && member.aiterm_session_id
420
+ ? member.aiterm_session_id
421
+ : observation.target
422
+ // Codex は実行中ターンへ steer(0.153 は「次の tool call 後に送る」キューへ積む)、idle なら dispatch。
423
+ // 実行中の判定は画面の実行中マーカーだけ(読むだけで、状態を変えない)。
424
+ const busy = classifyPaneTail(tail) === 'busy' || tail.includes('esc to interrupt')
425
+ const deliverScript = fileURLToPath(new URL('./aiterm-deliver.mjs', import.meta.url))
426
+ const deliverFile = join(proj, '.team', `wakeup-deliver-${process.pid}.txt`)
427
+ writeFileSync(deliverFile, text)
428
+ const deliver = async (mode) => {
429
+ const res = await run(process.execPath, [deliverScript, sessionId, deliverFile, `--mode=${mode}`],
430
+ { env: process.env, maxBuffer: 4 * 1024 * 1024 })
431
+ return JSON.parse(String(res.stdout || '{}'))
432
+ }
433
+ let mode = harness === 'codex' && busy ? 'steer' : 'dispatch'
434
+ let receipt
435
+ try {
436
+ receipt = await deliver(mode)
437
+ if (receipt?.schema === 'aiterm.agent-steer.v1' && receipt.delivery === 'idle') {
438
+ // steer した瞬間に idle へ落ちた。dispatch で届け直す(aiterm が ready gate を通す)
439
+ mode = 'dispatch'
440
+ receipt = await deliver(mode)
530
441
  }
531
- await sleep(750)
532
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
533
- }
534
- // 送信検証。Codex v0.148 は長文入力後に「Create a plan?」popup が出て Enter を呑むことがあり、
535
- // 本文が入力欄に残ったまま席が沈黙する(2026-08-22 実測: 2席が無音停止し、人が画面を見るまで
536
- // 誰も気づけなかった)。打鍵成功=配達成功とみなさず、画面で送信成立を確認する。
537
- // 残っていれば Escape(popup解除)→ Enter で再送信し、それでも残るなら受領を確定せず
538
- // 次周期に再試行する(毎回ログに残るので沈黙しない)。
539
- const markerSource = text.replace(/\s+/gu, '')
540
- const marker = markerSource.slice(-24)
541
- // Claude Code は長文入力を折り畳んで本文を画面から消し("paste again to expand" /
542
- // "[Pasted text")、ターン実行中は "esc to interrupt" でなくスピナー(✻✽·)を出す。
543
- // この2つで旧検証は「未送信」と誤判定し、しかも回復に撃つ Escape が **Claude の実行中
544
- // ターンを中断**していた(実被弾 2026-08-22: 監査席が2秒ごとに中断され、監査が進まなかった)。
545
- // Claude 席は「受理された兆候」を成功とみなし、回復キーに Escape を使わない。
546
- const CLAUDE_ACCEPTED = /paste again to expand|\[Pasted text|esc to interrupt|✻|✽|✳|·\s*\w+ing…/u
547
- const isClaude = memberHarness(member) === 'claude'
548
- // Codex席の成立判定は画面でなくrollout transcript(席CODEX_HOMEの正本記録)で行う。
549
- // 画面照合はTUIのレイアウト・描画タイミングごとに偽陽性/偽陰性を繰り返した(実被弾
550
- // 2026-08-29に4系統: 会話履歴の残存誤検知・マーカー不在時のフォールバック誤検知・
551
- // 受理済みキュー表示の誤検知・偽delivered)。submitされた本文はidle時ただちに
552
- // rolloutへuser messageとして書かれるので、それだけを受理の証拠とする。
553
- // 実行中ターン(esc to interrupt / to queue message)はCodexが入力をキューする実測
554
- // (2026-08-08)に基づき受理扱いを維持する。
555
- const codexSessionsDir = join(proj, '.team', 'seats', `${seat}.codex`, 'sessions')
556
- const rolloutHasMarker = () => {
557
- if (marker.length < 8) return false
558
- let newest = null; let newestM = -Infinity
559
- const walk = (dir) => {
560
- let entries
561
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
562
- for (const entry of entries) {
563
- const file = join(dir, entry.name)
564
- if (entry.isDirectory()) { walk(file); continue }
565
- if (!entry.name.startsWith('rollout-') || !entry.name.endsWith('.jsonl')) continue
566
- let m
567
- try { m = statSync(file).mtimeMs } catch { continue }
568
- if (m > newestM) { newestM = m; newest = file }
569
- }
442
+ } catch (e) {
443
+ const detail = String(e?.stderr || e?.message || e).split('\n').filter(Boolean).join(' / ').slice(0, 240)
444
+ if (/入力受付状態になりません|AGENT_TUI_BACKGROUNDED|AGENT_TTY_COOKED/u.test(detail)) {
445
+ // 席は在るが今は受け取れない(実行中・ダイアログ・前面回復不能)。次周期に再試行する
446
+ log(`配達できず次周期に再試行: ${seat} ${msgs.length} 件: ${detail}`)
447
+ return 'deferred'
570
448
  }
571
- walk(codexSessionsDir)
572
- if (newest === null) return false
573
- let bytes
574
- try {
575
- const size = statSync(newest).size
576
- const fd = openSync(newest, 'r')
577
- const len = Math.min(size, 262144)
578
- const buf = Buffer.alloc(len)
579
- readSync(fd, buf, 0, len, size - len)
580
- closeSync(fd)
581
- bytes = buf.toString('utf8')
582
- } catch { return false }
583
- return bytes.replace(/\s+/gu, '').includes(marker)
449
+ const error = new Error(`DELIVERY_FAILED: ${seat}: ${detail}`)
450
+ error.code = 'DELIVERY_FAILED'
451
+ throw error
452
+ } finally {
453
+ try { unlinkSync(deliverFile) } catch { /* 一時fileの掃除失敗は配達判定に影響しない */ }
584
454
  }
585
- const isCodex = memberHarness(member) === 'codex'
586
- for (let attempt = 0; attempt < 3; attempt++) {
587
- await sleep(1200)
588
- const after = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
589
- const tailLines = String(after.stdout ?? '').split('\n').slice(-14)
590
- const tailJoined = tailLines.join(' ')
591
- // 折返しはcapture上で行分割されるだけで空白は挟まらないため、空白除去で復元して照合する。
592
- // 照合は composer 領域だけ(composerSquashed の注記を参照)
593
- const tailSquashed = composerSquashed(String(after.stdout ?? ''))
594
- const queuedOrRunning = tailJoined.includes('esc to interrupt') || tailJoined.includes('to queue message')
595
- || (isClaude && CLAUDE_ACCEPTED.test(tailJoined))
596
- const stuck = isCodex
597
- ? (!queuedOrRunning && !rolloutHasMarker())
598
- : ((!isClaude && tailJoined.includes('esc dismiss'))
599
- || (marker.length >= 8 && tailSquashed.includes(marker) && !queuedOrRunning))
600
- if (!stuck) break
601
- if (attempt === 2) {
602
- // 蓄積戦争の禁止: 次周期は束ねる本文が変わり得て残存照合が外れ、全文再打鍵が
603
- // composer を多重本文で埋める(実被弾 2026-08-29 に2回)。STUCKで周期を明け渡す前に
604
- // composer を行数分の C-u で空にし、次周期が常に「空の入力欄へ1部だけ」から始まる
605
- // 構造にする。
606
- const wipePane = await run('tmux', tmuxArgv(['capture-pane', '-t', observation.target, '-p'], { socket: observation.socket }))
607
- const wipeLines = Math.min(120, String(wipePane.stdout ?? '').split('\n').length + 10)
608
- for (let i = 0; i < wipeLines; i++) {
609
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'C-u'], { socket: observation.socket }))
610
- }
611
- log(`DELIVERY_STUCK: ${seat} の入力欄に本文が残ったまま送信できない(popup/入力詰まり)。入力欄を掃除し、受領を確定せず次周期に再試行する`)
612
- const error = new Error(`DELIVERY_STUCK: ${seat}`)
613
- error.code = 'DELIVERY_STUCK'
614
- throw error
615
- }
616
- if (isClaude) {
617
- // Escape は撃たない(実行中ターンを殺す)。Enter だけ打ち直す
618
- log(`配達が送信されていない: ${seat}(Claude 席のため Escape は使わず Enter を打ち直す)`)
619
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
620
- continue
621
- }
622
- // Escape は popup が無い時に composer へリテラル ESC として混入し、本文を恒久汚染する
623
- // (実被弾 2026-08-29: `^[` が本文中へ入り submit 不能が固定化)。popup 解除は
624
- // 「esc dismiss」表示がある時だけ撃ち、それ以外は Enter だけ打ち直す。
625
- if (tailJoined.includes('esc dismiss')) {
626
- log(`配達が送信されていない(popup表示中): ${seat}。Escape→Enter で再送信する`)
627
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Escape'], { socket: observation.socket }))
628
- await sleep(300)
629
- } else {
630
- log(`配達が送信されていない(入力詰まり): ${seat}。Enter を打ち直す`)
631
- }
632
- await run('tmux', tmuxArgv(['send-keys', '-t', observation.target, 'Enter'], { socket: observation.socket }))
455
+ if (receipt?.submit_residue === true) {
456
+ // aiterm composer への残存を観測=submit 未成立の疑い。受領を確定せず次周期に再試行する
457
+ log(`DELIVERY_STUCK: ${seat} の composer に本文が残存(aiterm submit_residue=true)。受領を確定せず次周期に再試行する`)
458
+ const error = new Error(`DELIVERY_STUCK: ${seat}`)
459
+ error.code = 'DELIVERY_STUCK'
460
+ throw error
633
461
  }
634
- log(`TUIへ入れた: ${seat} ${msgs.length} 件(最新 seq ${last.seq})`)
462
+ const recovered = Array.isArray(receipt?.pane_input_recovery) && receipt.pane_input_recovery.length
463
+ ? `・回復 ${receipt.pane_input_recovery.join(',')}`
464
+ : ''
465
+ log(`TUIへ入れた: ${seat} ← ${msgs.length} 件(最新 seq ${last.seq}・${mode}${recovered})`)
635
466
  }
636
467
 
637
468
  function dispatch(msg) {