@cocorograph/hub-agent 0.7.9 → 0.7.11
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/src/extract-paragraph.mjs +94 -0
- package/src/main.mjs +202 -51
- package/src/tmux.mjs +29 -0
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude TUI (Claude Code 2.1.x) のペイン出力から、
|
|
3
|
+
* 「直近のアシスタント説明テキスト段落」を保守的に抽出する。
|
|
4
|
+
*
|
|
5
|
+
* 目的: PreToolUse フック発火時点で jsonl はまだ前ターン止まりのため、フックの
|
|
6
|
+
* transcript ベース context_text 抽出は前ターンの古い説明を掴む「取り違え」を起こす。
|
|
7
|
+
* 一方、claude TUI のペインには「いま回答中の assistant 本文」が既に描画されている
|
|
8
|
+
* ため、tmux capture-pane の結果からこれを抜き出して permission.request に同梱する
|
|
9
|
+
* のが回答前に説明文をユーザーへ届ける唯一の経路。
|
|
10
|
+
*
|
|
11
|
+
* 出力フォーマット観察 (2026-06-24 実機 claude 2.1.x):
|
|
12
|
+
* ⏺ <本文テキスト> ← アシスタント説明 (これを抽出したい)
|
|
13
|
+
* ⏺ Bash(<cmd>) ← ツール呼び出し (除外したい)
|
|
14
|
+
* ⏺ Read(...) ← ツール呼び出し
|
|
15
|
+
* ⎿ <結果> ← ツール結果
|
|
16
|
+
*
|
|
17
|
+
* 抽出戦略: ペイン末尾から後ろ向き走査で「⏺ で始まり、続く語がツール名形式
|
|
18
|
+
* (CamelCase + `(`) でない最初の行」を探し、そこから下方向に連続する「説明文の段落」
|
|
19
|
+
* を取る。少しでも自信が無いケースは null 返却して呼び出し側で「説明なし」へ縮退。
|
|
20
|
+
*
|
|
21
|
+
* 安全方針: 抽出失敗 = null (壊さない)。誤抽出より null の方が UX 上安全。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const ASSIST_PREFIX = "⏺"
|
|
25
|
+
|
|
26
|
+
const _MAX_CHARS = 2000
|
|
27
|
+
|
|
28
|
+
const _MIN_CHARS = 6
|
|
29
|
+
|
|
30
|
+
const _TOOL_CALL_RE = /^[A-Z][A-Za-z0-9_]*\s*\(/
|
|
31
|
+
|
|
32
|
+
const _BOX_LINE_RE = /^[─-▟]/
|
|
33
|
+
|
|
34
|
+
const _STATUS_LINE_RE = /^[✨✹●⚫◯]\s/
|
|
35
|
+
|
|
36
|
+
const _ELLIPSIS_TAIL_RE = /\n[ \t]*…\s*\+\d+\s+lines[^\n]*$/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string|undefined|null} paneText capturePane の出力 (ANSI 除去済み)
|
|
40
|
+
* @returns {string|null} 直近のアシスタント説明 (失敗時 null)
|
|
41
|
+
*/
|
|
42
|
+
export function extractLastAssistantText(paneText) {
|
|
43
|
+
if (typeof paneText !== "string" || !paneText) return null
|
|
44
|
+
const lines = paneText.replace(/\r/g, "").split("\n")
|
|
45
|
+
|
|
46
|
+
let startIdx = -1
|
|
47
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
48
|
+
const line = lines[i]
|
|
49
|
+
if (!line.startsWith(ASSIST_PREFIX + " ")) continue
|
|
50
|
+
const rest = line.slice(ASSIST_PREFIX.length + 1).trimStart()
|
|
51
|
+
if (_TOOL_CALL_RE.test(rest)) continue
|
|
52
|
+
startIdx = i
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
if (startIdx < 0) return null
|
|
56
|
+
|
|
57
|
+
const parts = []
|
|
58
|
+
let consecBlank = 0
|
|
59
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
60
|
+
const line = lines[i]
|
|
61
|
+
if (i > startIdx) {
|
|
62
|
+
if (line.startsWith(ASSIST_PREFIX + " ")) break
|
|
63
|
+
if (_BOX_LINE_RE.test(line)) break
|
|
64
|
+
if (_STATUS_LINE_RE.test(line.trim())) break
|
|
65
|
+
}
|
|
66
|
+
if (!line.trim()) {
|
|
67
|
+
consecBlank++
|
|
68
|
+
if (consecBlank >= 2) break
|
|
69
|
+
parts.push(line)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
consecBlank = 0
|
|
73
|
+
parts.push(line)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let text = parts.join("\n").trimEnd()
|
|
77
|
+
if (text.startsWith(ASSIST_PREFIX)) {
|
|
78
|
+
text = text.slice(ASSIST_PREFIX.length).trimStart()
|
|
79
|
+
}
|
|
80
|
+
text = text.replace(_ELLIPSIS_TAIL_RE, "").trim()
|
|
81
|
+
if (text.length < _MIN_CHARS) return null
|
|
82
|
+
|
|
83
|
+
if (text.length > _MAX_CHARS) {
|
|
84
|
+
text = "…" + text.slice(-_MAX_CHARS)
|
|
85
|
+
}
|
|
86
|
+
return text
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const __test = {
|
|
90
|
+
_MAX_CHARS,
|
|
91
|
+
_MIN_CHARS,
|
|
92
|
+
_TOOL_CALL_RE,
|
|
93
|
+
_BOX_LINE_RE,
|
|
94
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import path from "node:path"
|
|
|
20
20
|
import pino from "pino"
|
|
21
21
|
|
|
22
22
|
import { detectPlatform, readConfig, writeConfig } from "./config.mjs"
|
|
23
|
+
import { extractLastAssistantText } from "./extract-paragraph.mjs"
|
|
23
24
|
import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
|
|
24
25
|
import { WsClient } from "./ws-client.mjs"
|
|
25
26
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
removeWorktree as removeWorktreeDir,
|
|
77
78
|
resumeWithMessage,
|
|
78
79
|
pasteToSessionByName,
|
|
80
|
+
sendInterruptKey,
|
|
79
81
|
setSessionMouse,
|
|
80
82
|
setTmuxGlobalEnv,
|
|
81
83
|
setTuiEffort,
|
|
@@ -669,7 +671,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
669
671
|
const tuiPermissionBridge = new TuiPermissionBridge({ logger })
|
|
670
672
|
tuiPermissionBridge.on(
|
|
671
673
|
"permission",
|
|
672
|
-
({ request_id, session_id, cwd, tool_name, input, context_text }) => {
|
|
674
|
+
async ({ request_id, session_id, cwd, tool_name, input, context_text }) => {
|
|
673
675
|
if (cwd) {
|
|
674
676
|
try {
|
|
675
677
|
recordChatActivity(cwd, { inputPending: true })
|
|
@@ -677,6 +679,33 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
677
679
|
/* ignore */
|
|
678
680
|
}
|
|
679
681
|
}
|
|
682
|
+
// ── 直前アシスタント説明の抽出 (T05xxx 根治) ────────────────────────────
|
|
683
|
+
// PreToolUse フック発火時点で jsonl はまだ前ターン止まり (フックがブロック
|
|
684
|
+
// している間 claude は jsonl を commit しない) のため、フック側の transcript
|
|
685
|
+
// 抽出は前ターンの古い説明を掴む「取り違え」を起こす。
|
|
686
|
+
// 代わりに hub-agent がここで tmux capture-pane を打ち、TUI のペイン上に
|
|
687
|
+
// 既に描画されている「現ターンの assistant 本文段落 (⏺ プレフィックス)」を
|
|
688
|
+
// 抜き出して context_text に注入する。フックが既に context_text を
|
|
689
|
+
// 持っている場合 (旧経路) はそれを尊重し、空 / null のときだけ上書きする。
|
|
690
|
+
// 抽出失敗時は null フォールバック (= 説明なし) で安全縮退。
|
|
691
|
+
let liveContext = null
|
|
692
|
+
if (!context_text && cwd) {
|
|
693
|
+
try {
|
|
694
|
+
const sessions = await listTmuxSessions({ logger })
|
|
695
|
+
const match = Array.isArray(sessions)
|
|
696
|
+
? sessions.find((s) => s && s.cwd === cwd)
|
|
697
|
+
: null
|
|
698
|
+
if (match && match.name) {
|
|
699
|
+
const pane = await capturePane(match.name, { noCache: true })
|
|
700
|
+
liveContext = extractLastAssistantText(pane)
|
|
701
|
+
}
|
|
702
|
+
} catch (err) {
|
|
703
|
+
logger?.debug?.(
|
|
704
|
+
{ err: err?.message, cwd },
|
|
705
|
+
"capture-pane based context_text extraction failed; falling back to null",
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
680
709
|
client.send({
|
|
681
710
|
type: "claude.permission.request",
|
|
682
711
|
stream_id: null,
|
|
@@ -685,8 +714,10 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
685
714
|
request_id,
|
|
686
715
|
tool_name,
|
|
687
716
|
input,
|
|
688
|
-
// 質問/承認カードの直前アシスタント説明 (
|
|
689
|
-
|
|
717
|
+
// 質問/承認カードの直前アシスタント説明 (browser がカード上部に表示)。
|
|
718
|
+
// 優先順位: (1) フックが既に同梱した context_text、(2) hub-agent が
|
|
719
|
+
// tmux capture-pane で抽出した liveContext、(3) null。
|
|
720
|
+
context_text: context_text || liveContext || null,
|
|
690
721
|
})
|
|
691
722
|
},
|
|
692
723
|
)
|
|
@@ -1519,6 +1550,156 @@ export function handleUntrackedPtyData(msg, ctx) {
|
|
|
1519
1550
|
const _seenFlushIds = new Set()
|
|
1520
1551
|
const _SEEN_FLUSH_CAP = 2000
|
|
1521
1552
|
|
|
1553
|
+
/**
|
|
1554
|
+
* claude.tui.queue.flush の処理 (離席中キュー自動投入の agent 側受け口)。
|
|
1555
|
+
* ブラウザの常駐ドレイン (useCockpitTuiQueueDrainAll) が、表示していないセッションの送信待ち
|
|
1556
|
+
* キュー先頭 1 件をここへ送る。tmux send-keys (PTY 非依存) で投入するので、そのセッションの
|
|
1557
|
+
* ターミナルを誰も開いていなくても届く。結果を flush.ack で返し、ブラウザは ack を受けて
|
|
1558
|
+
* はじめて localStorage から 1 件 dequeue する。
|
|
1559
|
+
*
|
|
1560
|
+
* 冪等性は 2 段:
|
|
1561
|
+
* 1. flush_id 単位 (_seenFlushIds): WS 再送・ack 取りこぼしによる同一 flush の二重投入を防ぐ。
|
|
1562
|
+
* 2. 論理 item id 単位 (cross-path): browser の active 経路 (tracked PTY paste) は同一 item を
|
|
1563
|
+
* input_id `qd:<itemId>:p` / `:r` で送る。ここと単一レジストリ (seenInputIds) を共有し、同一
|
|
1564
|
+
* item がどちらの経路で配送されても agent は at-most-once でしか paste しない (二重送信の根治)。
|
|
1565
|
+
*
|
|
1566
|
+
* テストから直接呼べるよう dispatch から切り出して export する。paste 実体は ctx で差し替え可能。
|
|
1567
|
+
*/
|
|
1568
|
+
export async function handleQueueFlush(msg, ctx) {
|
|
1569
|
+
const paste = ctx.pasteToSessionByName || pasteToSessionByName
|
|
1570
|
+
const sessionName =
|
|
1571
|
+
typeof msg.session_name === "string" ? msg.session_name : ""
|
|
1572
|
+
const text = typeof msg.text === "string" ? msg.text : ""
|
|
1573
|
+
const flushId = typeof msg.flush_id === "string" ? msg.flush_id : ""
|
|
1574
|
+
const itemId = typeof msg.item_id === "string" ? msg.item_id : ""
|
|
1575
|
+
const pasteKey = itemId ? `qd:${itemId}:p` : ""
|
|
1576
|
+
const crKey = itemId ? `qd:${itemId}:r` : ""
|
|
1577
|
+
const ackFail = (reason) => {
|
|
1578
|
+
if (flushId && sessionName) {
|
|
1579
|
+
ctx.client.send({
|
|
1580
|
+
type: "claude.tui.queue.flush.ack",
|
|
1581
|
+
session_name: sessionName,
|
|
1582
|
+
flush_id: flushId,
|
|
1583
|
+
ok: false,
|
|
1584
|
+
error: reason,
|
|
1585
|
+
})
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const ackOk = () => {
|
|
1589
|
+
if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
|
|
1590
|
+
_seenFlushIds.add(flushId)
|
|
1591
|
+
ctx.client.send({
|
|
1592
|
+
type: "claude.tui.queue.flush.ack",
|
|
1593
|
+
session_name: sessionName,
|
|
1594
|
+
flush_id: flushId,
|
|
1595
|
+
ok: true,
|
|
1596
|
+
})
|
|
1597
|
+
}
|
|
1598
|
+
if (!sessionName || !flushId || !text) {
|
|
1599
|
+
ackFail("missing session_name / flush_id / text")
|
|
1600
|
+
return
|
|
1601
|
+
}
|
|
1602
|
+
// 冪等化1 (message 再送)。
|
|
1603
|
+
if (_seenFlushIds.has(flushId)) {
|
|
1604
|
+
ackOk()
|
|
1605
|
+
return
|
|
1606
|
+
}
|
|
1607
|
+
// 冪等化2 (cross-path): 同一 item を active 経路 (tracked PTY paste) が既に配送済みなら
|
|
1608
|
+
// 再 paste せず ok を返す (browser は ack で dequeue する)。
|
|
1609
|
+
if (pasteKey && seenInputIds.has(pasteKey)) {
|
|
1610
|
+
ackOk()
|
|
1611
|
+
return
|
|
1612
|
+
}
|
|
1613
|
+
// paste 前に cross-path キーを登録する。await 中に並走する active 経路の tracked paste
|
|
1614
|
+
// (handleTrackedPtyData) を dedup させ二重 paste を防ぐため (登録は paste 完了より先)。
|
|
1615
|
+
// 失敗時は登録解除し、再試行 (別 flush_id) で再配送できるようにする。
|
|
1616
|
+
if (pasteKey) {
|
|
1617
|
+
rememberInputId(pasteKey)
|
|
1618
|
+
rememberInputId(crKey)
|
|
1619
|
+
}
|
|
1620
|
+
const result = await paste(sessionName, text, { logger: ctx.logger })
|
|
1621
|
+
if (result.ok) {
|
|
1622
|
+
if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
|
|
1623
|
+
_seenFlushIds.add(flushId)
|
|
1624
|
+
} else if (pasteKey) {
|
|
1625
|
+
seenInputIds.delete(pasteKey)
|
|
1626
|
+
seenInputIds.delete(crKey)
|
|
1627
|
+
}
|
|
1628
|
+
ctx.client.send({
|
|
1629
|
+
type: "claude.tui.queue.flush.ack",
|
|
1630
|
+
session_name: sessionName,
|
|
1631
|
+
flush_id: flushId,
|
|
1632
|
+
ok: !!result.ok,
|
|
1633
|
+
error: result.ok ? undefined : result.error,
|
|
1634
|
+
})
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* claude.tui.interrupt の処理 (確認付き中断 = Phase3)。生 ESC を tracked pty.data で best-effort
|
|
1639
|
+
* 送出する旧経路 (stream 欠落で無言ドロップ + ESC 到達でも止まったか未検証) を、agent が ESC を
|
|
1640
|
+
* tmux send-keys (PTY 非依存で確実配達) で送り、ペイン status で生成停止を観測してから結果を返す
|
|
1641
|
+
* 方式に置き換える。
|
|
1642
|
+
*
|
|
1643
|
+
* 不変条件: 中断指示は、claude と同一ホストで生成状態を観測できる agent が「停止を観測 or deadline」
|
|
1644
|
+
* まで責任を持つ。止まらなければ deadline 内で 1 度だけ ESC を再送する (初回が一過性 mode に吸われた
|
|
1645
|
+
* 場合の保険)。Ctrl+C へはエスカレーションしない (claude を終了させる事故を避けるため。別判断)。
|
|
1646
|
+
*
|
|
1647
|
+
* テストから直接呼べるよう export。送出 (sendInterruptKey) / 観測 (detectSessionState) / 待機 (delay)
|
|
1648
|
+
* は ctx で差し替え可能。
|
|
1649
|
+
*/
|
|
1650
|
+
export async function handleTuiInterrupt(msg, ctx) {
|
|
1651
|
+
const sessionName =
|
|
1652
|
+
typeof msg.session_name === "string" ? msg.session_name : ""
|
|
1653
|
+
const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
|
|
1654
|
+
const requestId = typeof msg.request_id === "string" ? msg.request_id : ""
|
|
1655
|
+
if (!sessionName || !requestId) return
|
|
1656
|
+
const sendEsc = ctx.sendInterruptKey || sendInterruptKey
|
|
1657
|
+
const detect = ctx.detectSessionState || detectSessionState
|
|
1658
|
+
const delay = ctx.delay || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
1659
|
+
const settleMs = ctx.interruptSettleMs ?? 350
|
|
1660
|
+
const pollMs = ctx.interruptPollMs ?? 350
|
|
1661
|
+
const maxPolls = ctx.interruptMaxPolls ?? 7 // ~2.5s deadline
|
|
1662
|
+
const reply = (stopped, attempts) =>
|
|
1663
|
+
ctx.client.send({
|
|
1664
|
+
type: "claude.tui.interrupt.result",
|
|
1665
|
+
request_id: requestId,
|
|
1666
|
+
session_name: sessionName,
|
|
1667
|
+
cwd: cwd || undefined,
|
|
1668
|
+
stopped: !!stopped,
|
|
1669
|
+
attempts,
|
|
1670
|
+
})
|
|
1671
|
+
const stillProcessing = async () => {
|
|
1672
|
+
try {
|
|
1673
|
+
const snap = await detect(sessionName, { logger: ctx.logger })
|
|
1674
|
+
return (snap?.status ?? "processing") === "processing"
|
|
1675
|
+
} catch {
|
|
1676
|
+
// capture 失敗時は判定不能 → 保守的に「まだ生成中かも」と扱う (誤って stopped=true にしない)。
|
|
1677
|
+
return true
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
// 1) ESC 送出 (= 中断)。
|
|
1681
|
+
await sendEsc(sessionName, { logger: ctx.logger })
|
|
1682
|
+
let attempts = 1
|
|
1683
|
+
let resent = false
|
|
1684
|
+
// 2) 停止 (status が processing を離れる) を deadline 内で観測。
|
|
1685
|
+
for (let i = 0; i < maxPolls; i++) {
|
|
1686
|
+
await delay(i === 0 ? settleMs : pollMs)
|
|
1687
|
+
if (!(await stillProcessing())) {
|
|
1688
|
+
reply(true, attempts)
|
|
1689
|
+
return
|
|
1690
|
+
}
|
|
1691
|
+
// deadline 中盤でまだ processing なら 1 度だけ ESC 再送 (初回が一過性 mode に吸われた保険)。
|
|
1692
|
+
// status=processing 継続が条件なので、アイドル箱への二度押し (rewind ダイアログ誤爆) は避ける。
|
|
1693
|
+
if (!resent && i >= Math.floor(maxPolls / 2)) {
|
|
1694
|
+
resent = true
|
|
1695
|
+
await sendEsc(sessionName, { logger: ctx.logger })
|
|
1696
|
+
attempts++
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
// 3) deadline 到達でまだ processing。停止確認できず (frontend は旧 tracked-ESC 経路へ degrade)。
|
|
1700
|
+
reply(false, attempts)
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1522
1703
|
async function dispatch(msg, ctx) {
|
|
1523
1704
|
const t = msg?.type || ""
|
|
1524
1705
|
try {
|
|
@@ -1865,6 +2046,15 @@ async function dispatch(msg, ctx) {
|
|
|
1865
2046
|
old_session_id: viewSid,
|
|
1866
2047
|
new_session_id: newSessionId,
|
|
1867
2048
|
})
|
|
2049
|
+
// ★回転 (= session_id 境界) で、この NAME に紐づく状態トラッカーを忘れる。生成中を表す
|
|
2050
|
+
// 信号 (proc_busy=ReadinessTracker baseline/armed, status gate ラッチ, spinner freeze,
|
|
2051
|
+
// capture cache) は全て NAME キーのため、忘れないと旧ターンの busy が新 session_id の
|
|
2052
|
+
// 表示へ漏れ、フロントで「/clear 後に三点リーダーが固着」する。発生源 (agent) で断つのが
|
|
2053
|
+
// 主、フロントの再点灯レジスタ reset が従の不変条件 (name→id 漏れの根治)。
|
|
2054
|
+
if (viewName) {
|
|
2055
|
+
ctx.readinessTracker?.forget(viewName)
|
|
2056
|
+
invalidateSessionCache(viewName)
|
|
2057
|
+
}
|
|
1868
2058
|
logger.info(
|
|
1869
2059
|
{ session: viewName, cwd: viewCwd, old: viewSid, new: newSessionId },
|
|
1870
2060
|
"tui session rotated (/clear) → notified browser",
|
|
@@ -2060,56 +2250,17 @@ async function dispatch(msg, ctx) {
|
|
|
2060
2250
|
})()
|
|
2061
2251
|
return
|
|
2062
2252
|
}
|
|
2253
|
+
case "claude.tui.interrupt": {
|
|
2254
|
+
// 確認付き中断 (Phase3)。ロジックは handleTuiInterrupt に切り出し (テスト可能化)。
|
|
2255
|
+
// async だが dispatch をブロックしないよう投げっぱなし (自前で result を返す)。
|
|
2256
|
+
void handleTuiInterrupt(msg, ctx)
|
|
2257
|
+
return
|
|
2258
|
+
}
|
|
2063
2259
|
case "claude.tui.queue.flush": {
|
|
2064
2260
|
// 離席中(非アクティブセッション)キュー自動投入 (案A の agent 側受け口)。
|
|
2065
|
-
//
|
|
2066
|
-
//
|
|
2067
|
-
|
|
2068
|
-
// ブラウザは ack を受けてはじめて localStorage から 1 件 dequeue する (冪等)。
|
|
2069
|
-
const sessionName =
|
|
2070
|
-
typeof msg.session_name === "string" ? msg.session_name : ""
|
|
2071
|
-
const text = typeof msg.text === "string" ? msg.text : ""
|
|
2072
|
-
const flushId = typeof msg.flush_id === "string" ? msg.flush_id : ""
|
|
2073
|
-
const ackFail = (reason) => {
|
|
2074
|
-
if (flushId && sessionName) {
|
|
2075
|
-
ctx.client.send({
|
|
2076
|
-
type: "claude.tui.queue.flush.ack",
|
|
2077
|
-
session_name: sessionName,
|
|
2078
|
-
flush_id: flushId,
|
|
2079
|
-
ok: false,
|
|
2080
|
-
error: reason,
|
|
2081
|
-
})
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
if (!sessionName || !flushId || !text) {
|
|
2085
|
-
ackFail("missing session_name / flush_id / text")
|
|
2086
|
-
return
|
|
2087
|
-
}
|
|
2088
|
-
// 冪等化: 同一 flush_id を既に処理済みなら再 paste せず ok を返す (ブラウザは dequeue 済み
|
|
2089
|
-
// を再確認するだけ)。WS 再送・ack 取りこぼしによる二重投入を防ぐ。
|
|
2090
|
-
if (_seenFlushIds.has(flushId)) {
|
|
2091
|
-
ctx.client.send({
|
|
2092
|
-
type: "claude.tui.queue.flush.ack",
|
|
2093
|
-
session_name: sessionName,
|
|
2094
|
-
flush_id: flushId,
|
|
2095
|
-
ok: true,
|
|
2096
|
-
})
|
|
2097
|
-
return
|
|
2098
|
-
}
|
|
2099
|
-
;(async () => {
|
|
2100
|
-
const result = await pasteToSessionByName(sessionName, text, { logger })
|
|
2101
|
-
if (result.ok) {
|
|
2102
|
-
if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
|
|
2103
|
-
_seenFlushIds.add(flushId)
|
|
2104
|
-
}
|
|
2105
|
-
ctx.client.send({
|
|
2106
|
-
type: "claude.tui.queue.flush.ack",
|
|
2107
|
-
session_name: sessionName,
|
|
2108
|
-
flush_id: flushId,
|
|
2109
|
-
ok: !!result.ok,
|
|
2110
|
-
error: result.ok ? undefined : result.error,
|
|
2111
|
-
})
|
|
2112
|
-
})()
|
|
2261
|
+
// ロジックは handleQueueFlush に切り出し (テスト可能化 + cross-path 冪等)。async だが
|
|
2262
|
+
// dispatch をブロックしないよう投げっぱなしにする (自前で flush.ack を返す)。
|
|
2263
|
+
void handleQueueFlush(msg, ctx)
|
|
2113
2264
|
return
|
|
2114
2265
|
}
|
|
2115
2266
|
case "claude.tui.rehydratePermissions": {
|
package/src/tmux.mjs
CHANGED
|
@@ -964,6 +964,35 @@ export async function resumeWithMessage(name, text, opts = {}) {
|
|
|
964
964
|
}
|
|
965
965
|
}
|
|
966
966
|
|
|
967
|
+
/**
|
|
968
|
+
* 対話 TUI claude へ中断 (Esc) を tmux send-keys で送る。PTY stream に依存しないため、
|
|
969
|
+
* zombie WS / stream 欠落窓でも確実に届く (生 ESC を tracked pty.data で送る旧経路は stream
|
|
970
|
+
* 欠落で無言ドロップしていた = 症状4の配達失敗半分)。copy-mode 等に入っているとキーが奪われる
|
|
971
|
+
* ので先に -X cancel で抜ける。ESC は claude TUI の生成中断キー (素の入力欄では概ね無害)。
|
|
972
|
+
* @param {string} name tmux セッション名
|
|
973
|
+
* @param {{logger?:object,tmuxBin?:string}} [opts]
|
|
974
|
+
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
975
|
+
*/
|
|
976
|
+
export async function sendInterruptKey(name, opts = {}) {
|
|
977
|
+
if (!isSafeSessionName(name)) return { ok: false, error: "unsafe session name" }
|
|
978
|
+
const bin = tmuxBin(opts)
|
|
979
|
+
try {
|
|
980
|
+
try {
|
|
981
|
+
await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
|
|
982
|
+
} catch {
|
|
983
|
+
/* copy-mode でなければ "not in a mode" エラー = 無視 */
|
|
984
|
+
}
|
|
985
|
+
await execFileP(bin, ["send-keys", "-t", name, "Escape"])
|
|
986
|
+
return { ok: true }
|
|
987
|
+
} catch (err) {
|
|
988
|
+
opts.logger?.warn(
|
|
989
|
+
{ session: name, err: err?.message },
|
|
990
|
+
"sendInterruptKey failed",
|
|
991
|
+
)
|
|
992
|
+
return { ok: false, error: err?.message || "send_failed" }
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
967
996
|
/**
|
|
968
997
|
* 離席中キュー投入: 送信待ちメッセージ 1 件を、ブラウザがそのセッションのターミナルを
|
|
969
998
|
* マウントしていなくても tmux send-keys で対話 claude TUI へ投入する (案A の agent 側実体)。
|