@cocorograph/hub-agent 0.8.98 → 0.8.100
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/claude-stream-bridge.mjs +48 -45
- package/src/main.mjs +67 -12
package/package.json
CHANGED
|
@@ -427,6 +427,9 @@ function isSessionRunningEvent(msg) {
|
|
|
427
427
|
* 正しく終端として扱う (ガードが固着して _busy が永久 true になる = T06052 の再発を防ぐ安全弁)。 */
|
|
428
428
|
const STALE_IDLE_GUARD_MS = 5000
|
|
429
429
|
|
|
430
|
+
// sibling CLI の既定待ち 10 秒より短く受理状態を待つ。超過は失敗と断定しない。
|
|
431
|
+
export const SIBLING_ACCEPT_WAIT_MS = 1000
|
|
432
|
+
|
|
430
433
|
/** per-message の title control request を待って InputQueue を閉じる上限。 */
|
|
431
434
|
const SESSION_TITLE_CLOSE_WAIT_MS = 15_000
|
|
432
435
|
|
|
@@ -1408,7 +1411,7 @@ export class ClaudeStreamSession {
|
|
|
1408
1411
|
"resident busy, message queued",
|
|
1409
1412
|
)
|
|
1410
1413
|
this._emitQueueState()
|
|
1411
|
-
return { accepted: true }
|
|
1414
|
+
return { accepted: true, queued: true }
|
|
1412
1415
|
}
|
|
1413
1416
|
if (this._closed) return { accepted: false, reason: "queue_dead" }
|
|
1414
1417
|
this._beginResidentTurn(prompt)
|
|
@@ -1460,7 +1463,7 @@ export class ClaudeStreamSession {
|
|
|
1460
1463
|
"claude busy, message queued",
|
|
1461
1464
|
)
|
|
1462
1465
|
this._emitQueueState()
|
|
1463
|
-
return { accepted: true }
|
|
1466
|
+
return { accepted: true, queued: true }
|
|
1464
1467
|
}
|
|
1465
1468
|
// Phase 2 (major#1): per-message idle 直接実行も started を出さないため、注入なら source 付きで
|
|
1466
1469
|
// 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
|
|
@@ -3695,7 +3698,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3695
3698
|
*
|
|
3696
3699
|
* @param {string} cwd
|
|
3697
3700
|
* @param {string} text
|
|
3698
|
-
* @returns {
|
|
3701
|
+
* @returns {object | Promise<object>} 配送先なし/曖昧は同期、配送先確定後は受理結果を待つ。
|
|
3699
3702
|
*/
|
|
3700
3703
|
sendToCwd(cwd, text, source) {
|
|
3701
3704
|
if (!cwd || !text) return { status: "not_found" }
|
|
@@ -3712,32 +3715,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3712
3715
|
}
|
|
3713
3716
|
}
|
|
3714
3717
|
const { session, attached: att } = tier[0]
|
|
3715
|
-
|
|
3716
|
-
// handle を控え、失敗時にその entry だけを除去する (round2: text 除去は並行同一本文で誤消費)。
|
|
3717
|
-
const injectHandle = session._recordInjectSource?.(text, source)
|
|
3718
|
-
session
|
|
3719
|
-
.sendMessage({ role: "user", content: text })
|
|
3720
|
-
.then((r) => {
|
|
3721
|
-
// Phase 2 (major#3): sendMessage は throw せず {accepted:false} で失敗を返す経路がある
|
|
3722
|
-
// (queue_dead / empty_message / pre-canceled)。この場合は echo/started は来ないので
|
|
3723
|
-
// stale な live source entry を掃除し、**永続化もしない** (persist round1 major#1: 失敗を
|
|
3724
|
-
// 永続化すると 30 日 stale が残り後続の同文 human を誤カード化する)。
|
|
3725
|
-
if (r && r.accepted === false) {
|
|
3726
|
-
session._removeInjectSource?.(injectHandle)
|
|
3727
|
-
return
|
|
3728
|
-
}
|
|
3729
|
-
// 受理された (queued/started) ときだけ hydrate 用に永続化する (best-effort、live と独立)。
|
|
3730
|
-
recordInjectSourcePersistent(cwd, text, source).catch(() => {})
|
|
3731
|
-
})
|
|
3732
|
-
.catch((err) => {
|
|
3733
|
-
// 配送失敗 (throw) でも echo は来ないので stale な live entry を掃除する (永続化はしない)。
|
|
3734
|
-
session._removeInjectSource?.(injectHandle)
|
|
3735
|
-
this.logger?.error(
|
|
3736
|
-
{ cwd, err: err?.message },
|
|
3737
|
-
"sendToCwd: sendMessage threw unexpectedly",
|
|
3738
|
-
)
|
|
3739
|
-
})
|
|
3740
|
-
return { status: "delivered", session: this._describeCandidate(session, att) }
|
|
3718
|
+
return this._sendSiblingMessage(cwd, text, source, session, att)
|
|
3741
3719
|
}
|
|
3742
3720
|
|
|
3743
3721
|
/**
|
|
@@ -3761,7 +3739,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3761
3739
|
* @param {string} cwd 呼び出し元がトークン検証した cwd (この cwd のセッションにのみ注入)
|
|
3762
3740
|
* @param {string} sessionId 宛先 session_id (describeLiveSessions の session_id)
|
|
3763
3741
|
* @param {string} text
|
|
3764
|
-
* @returns {
|
|
3742
|
+
* @returns {object | Promise<object>} 配送先なし/曖昧は同期、配送先確定後は受理結果を待つ。
|
|
3765
3743
|
*/
|
|
3766
3744
|
sendToStream(cwd, sessionId, text, source) {
|
|
3767
3745
|
if (!cwd || !sessionId || !text) return { status: "not_found" }
|
|
@@ -3780,28 +3758,53 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3780
3758
|
}
|
|
3781
3759
|
}
|
|
3782
3760
|
const { session, attached } = matches[0]
|
|
3783
|
-
|
|
3761
|
+
return this._sendSiblingMessage(cwd, text, source, session, attached)
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
/** 受理結果だけを有限時間待つ。遅れて確定した結果も source の後始末には使う。 */
|
|
3765
|
+
async _sendSiblingMessage(cwd, text, source, session, attached) {
|
|
3766
|
+
const target = this._describeCandidate(session, attached)
|
|
3767
|
+
const delivered = { status: "delivered", session: target }
|
|
3784
3768
|
const injectHandle = session._recordInjectSource?.(text, source)
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
if (r && r.accepted === false) {
|
|
3769
|
+
let timer
|
|
3770
|
+
const outcome = (async () => {
|
|
3771
|
+
try {
|
|
3772
|
+
const result = await session.sendMessage({ role: "user", content: text })
|
|
3773
|
+
if (result?.accepted === false) {
|
|
3791
3774
|
session._removeInjectSource?.(injectHandle)
|
|
3792
|
-
return
|
|
3775
|
+
return { status: "delivery_failed", session: target, reason: result.reason }
|
|
3793
3776
|
}
|
|
3794
|
-
//
|
|
3777
|
+
// 受理時のみ永続化する。timeout 後に受理が確定した場合も維持する。
|
|
3795
3778
|
recordInjectSourcePersistent(cwd, text, source).catch(() => {})
|
|
3796
|
-
|
|
3797
|
-
|
|
3779
|
+
if (result?.queued === true) {
|
|
3780
|
+
return { status: "delivery_queued", session: target, reason: "queued_behind_active_turn" }
|
|
3781
|
+
}
|
|
3782
|
+
return delivered
|
|
3783
|
+
} catch (err) {
|
|
3798
3784
|
session._removeInjectSource?.(injectHandle)
|
|
3799
3785
|
this.logger?.error(
|
|
3800
|
-
{ session_id: sessionId, err: err?.message },
|
|
3801
|
-
"
|
|
3786
|
+
{ cwd, session_id: session.sessionId, err: err?.message },
|
|
3787
|
+
"sibling sendMessage threw unexpectedly; delivery remains unconfirmed",
|
|
3802
3788
|
)
|
|
3803
|
-
|
|
3804
|
-
|
|
3789
|
+
return delivered
|
|
3790
|
+
}
|
|
3791
|
+
})()
|
|
3792
|
+
try {
|
|
3793
|
+
return await Promise.race([
|
|
3794
|
+
outcome,
|
|
3795
|
+
new Promise((resolve) => {
|
|
3796
|
+
timer = setTimeout(() => {
|
|
3797
|
+
this.logger?.warn(
|
|
3798
|
+
{ cwd, session_id: session.sessionId, timeout_ms: SIBLING_ACCEPT_WAIT_MS },
|
|
3799
|
+
"sibling sendMessage acceptance timed out; delivery remains unconfirmed",
|
|
3800
|
+
)
|
|
3801
|
+
resolve(delivered)
|
|
3802
|
+
}, SIBLING_ACCEPT_WAIT_MS)
|
|
3803
|
+
}),
|
|
3804
|
+
])
|
|
3805
|
+
} finally {
|
|
3806
|
+
clearTimeout(timer)
|
|
3807
|
+
}
|
|
3805
3808
|
}
|
|
3806
3809
|
|
|
3807
3810
|
/**
|
package/src/main.mjs
CHANGED
|
@@ -1411,7 +1411,7 @@ export function updateSawUserTurn(name, event) {
|
|
|
1411
1411
|
* @param {number} at epoch ms
|
|
1412
1412
|
* @returns {{ sortAffecting: boolean, sortAffectingAt: number|undefined, stale: boolean }}
|
|
1413
1413
|
*/
|
|
1414
|
-
export function ingestSessionEventForSort(name, event, at, { fresh = false } = {}) {
|
|
1414
|
+
export function ingestSessionEventForSort(name, event, at, { fresh = false, sortable = true } = {}) {
|
|
1415
1415
|
const prevAt = lastEventAtByName.get(name) ?? 0
|
|
1416
1416
|
// stale (この session で処理済みの at より古い) read は状態を汚さない。arm や status は
|
|
1417
1417
|
// 呼び出し側で従来通り処理してよいが、sort へは一切効かせない。
|
|
@@ -1437,7 +1437,9 @@ export function ingestSessionEventForSort(name, event, at, { fresh = false } = {
|
|
|
1437
1437
|
}
|
|
1438
1438
|
if (typeof at === "number") lastEventAtByName.set(name, Math.max(prevAt, at))
|
|
1439
1439
|
updateSawUserTurn(name, event)
|
|
1440
|
-
|
|
1440
|
+
// `sortable=false`: 並び順の供給源ではない経路 (bundle hook のイベントファイル)。状態・readiness には
|
|
1441
|
+
// 使うが sort 時刻は動かさない (publishChatPromptSubmit / publishHookSessionEvent 参照)。
|
|
1442
|
+
const sortAffecting = sortable && computeSortAffecting(event, sawUserTurnByName.get(name) === true)
|
|
1441
1443
|
// sort_affecting=true で前進したときだけ sort 時刻を更新し、sidecar に永続化する (major#1)。
|
|
1442
1444
|
// event ファイルは揮発 (最新イベントで上書き) のため、再起動後に「最後の実ターン時刻」を
|
|
1443
1445
|
// 復元できるよう別ファイルに残す。冪等な再取込 (at 同値) では書かない。
|
|
@@ -1457,6 +1459,26 @@ export function getSortAffectingAt(name) {
|
|
|
1457
1459
|
return lastSortAffectingAtByName.get(name)
|
|
1458
1460
|
}
|
|
1459
1461
|
|
|
1462
|
+
/**
|
|
1463
|
+
* `chat.sessions` の各 live に、その行の「最後にユーザーが送信した時刻」(`sort_affecting_at`) を載せる。
|
|
1464
|
+
*
|
|
1465
|
+
* サイドバーの並び順の正本は送信時刻だけ (2026-07-29 決定)。frontend は `session.event` の push で
|
|
1466
|
+
* これを受け取るが、push はページを開いている間しか届かないため、再読み込み後に復元する手段が
|
|
1467
|
+
* 一覧応答しかない。値は sidecar で永続化済みのもの (seedSortAtFromSidecars) をそのまま返す。
|
|
1468
|
+
* 送信履歴の無い行はキーごと付けない (0 を入れると「送信済み」と区別できない)。
|
|
1469
|
+
*/
|
|
1470
|
+
export function withSortAffectingAt(snapshot, getSortAt = getSortAffectingAt) {
|
|
1471
|
+
const live = Array.isArray(snapshot?.live) ? snapshot.live : []
|
|
1472
|
+
return {
|
|
1473
|
+
...snapshot,
|
|
1474
|
+
live: live.map((item) => {
|
|
1475
|
+
const name = typeof item?.session_name === "string" ? item.session_name : ""
|
|
1476
|
+
const at = name ? getSortAt(name) : undefined
|
|
1477
|
+
return typeof at === "number" && at > 0 ? { ...item, sort_affecting_at: at } : item
|
|
1478
|
+
}),
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1460
1482
|
// sort 時刻 sidecar の接尾辞。`.json` にしない (readAllSessionEvents / pushFile が
|
|
1461
1483
|
// `.endsWith(".json")` で event ファイルと誤認しないように)。
|
|
1462
1484
|
const SORT_AT_SUFFIX = ".sortat"
|
|
@@ -1639,6 +1661,24 @@ async function readAllSessionEvents() {
|
|
|
1639
1661
|
return out
|
|
1640
1662
|
}
|
|
1641
1663
|
|
|
1664
|
+
/**
|
|
1665
|
+
* bundle hook が書いたイベントファイル 1 件 (`{ event, at }`) を取り込んで push する。
|
|
1666
|
+
*
|
|
1667
|
+
* ⚠️ hook のイベントは並び順に使わない (sortable: false)。並び順の正本は「ユーザーが送信した
|
|
1668
|
+
* 時刻」で、その供給源は agent の入力受付 (publishChatPromptSubmit) だけにする。hook の
|
|
1669
|
+
* prompt_submit は (1) 実行を始めた時刻なので、キュー待ちの送信では受付より後ろへずれて
|
|
1670
|
+
* 送信順を逆転させ、(2) 行名を cwd の basename から作るので、Cockpit 外で同じフォルダの
|
|
1671
|
+
* claude (headless `claude -p` 等) が動いただけでも行を浮かせる。状態・readiness には従来どおり使う。
|
|
1672
|
+
*/
|
|
1673
|
+
export async function publishHookSessionEvent(ctx, sessionName, data) {
|
|
1674
|
+
if (!data || typeof data.event !== "string") return undefined
|
|
1675
|
+
const at = typeof data.at === "number" ? data.at : Date.now()
|
|
1676
|
+
// チャットの行は state loop が返答付きの stop を送る。ここでも送ると Hub が完了通知を
|
|
1677
|
+
// 2 通 (定型文だけ + 返答付き) 出すため、送信だけ省く (readiness は取り込む)。
|
|
1678
|
+
const send = !(data.event === "stop" && chatStopNotifyingSessionNames.has(sessionName))
|
|
1679
|
+
return publishSessionEvent(ctx, sessionName, data.event, at, { send, sortable: false })
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1642
1682
|
/**
|
|
1643
1683
|
* `/tmp/cockpit_session_events/<tmux_session>.json` を fs.watch して
|
|
1644
1684
|
* UserPromptSubmit / Stop の event を `session.event` 型で Hub に push する。
|
|
@@ -1668,13 +1708,7 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
1668
1708
|
const full = path.join(SESSION_EVENTS_DIR, filename)
|
|
1669
1709
|
try {
|
|
1670
1710
|
const text = await readFile(full, "utf-8")
|
|
1671
|
-
|
|
1672
|
-
if (!data || typeof data.event !== "string") return
|
|
1673
|
-
const at = typeof data.at === "number" ? data.at : Date.now()
|
|
1674
|
-
// チャットの行は state loop が返答付きの stop を送る。ここでも送ると Hub が完了通知を
|
|
1675
|
-
// 2 通 (定型文だけ + 返答付き) 出すため、送信だけ省く (並び順・readiness は取り込む)。
|
|
1676
|
-
const send = !(data.event === "stop" && chatStopNotifyingSessionNames.has(sessionName))
|
|
1677
|
-
await publishSessionEvent({ client, readinessTracker, logger }, sessionName, data.event, at, { send })
|
|
1711
|
+
await publishHookSessionEvent({ client, readinessTracker, logger }, sessionName, JSON.parse(text))
|
|
1678
1712
|
} catch {
|
|
1679
1713
|
// 一時欠如 / parse 失敗等は無視 (次の変更で読み直す)
|
|
1680
1714
|
}
|
|
@@ -1722,20 +1756,39 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
1722
1756
|
*/
|
|
1723
1757
|
export const chatStopNotifyingSessionNames = new Set()
|
|
1724
1758
|
|
|
1759
|
+
/**
|
|
1760
|
+
* チャット入力の受付を並び順へ反映する (`prompt_submit`, 受付時刻, fresh)。
|
|
1761
|
+
*
|
|
1762
|
+
* 並び順の正本は「ユーザーが送信した時刻」だけ (2026-07-29 決定)。**これが唯一の供給源**で、
|
|
1763
|
+
* bundle hook のイベントファイルは並び順に使わない (publishHookSessionEvent の sortable: false)。
|
|
1764
|
+
* この直接 publish は tmux 撤去 (189c555) で、tmux 行を走査する旧経路ごと消えていた。行名は
|
|
1765
|
+
* cwd から直接解決する (chatRowSessionName が行名の正本)。publish の失敗で入力の配送を止めない。
|
|
1766
|
+
*/
|
|
1767
|
+
export function publishChatPromptSubmit(ctx, cwd, cliKind, at) {
|
|
1768
|
+
const name = chatRowSessionName(cwd, cliKind)
|
|
1769
|
+
if (!name) return
|
|
1770
|
+
const publish = ctx._publishSessionEvent ?? publishSessionEvent
|
|
1771
|
+
Promise.resolve()
|
|
1772
|
+
.then(() => publish({ client: ctx.client, logger: ctx.logger }, name, "prompt_submit", at, { fresh: true }))
|
|
1773
|
+
.catch((err) => {
|
|
1774
|
+
ctx.logger?.warn?.({ err: err?.message, cwd, cli_kind: cliKind, session_name: name }, "chat prompt_submit publish failed")
|
|
1775
|
+
})
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1725
1778
|
/** session-event をプロセス内で取り込み、並び順を更新して WS push する。 */
|
|
1726
1779
|
export async function publishSessionEvent(
|
|
1727
1780
|
{ client, readinessTracker, logger },
|
|
1728
1781
|
sessionName,
|
|
1729
1782
|
event,
|
|
1730
1783
|
at,
|
|
1731
|
-
{ fresh = false, arm = !fresh, text = "", send = true } = {},
|
|
1784
|
+
{ fresh = false, arm = !fresh, text = "", send = true, sortable = true } = {},
|
|
1732
1785
|
) {
|
|
1733
1786
|
// readiness (proc 内省の ready 判定) を動かすのはファイル監視経路だけ。直接 publish (fresh) は
|
|
1734
1787
|
// 「人間が送った」事実を並び順へ流すだけで、ターンの開始を意味しない (bridge が拒否すれば
|
|
1735
1788
|
// ターンは始まらないし、走査が逆順に完了した後着の古い方は既に終わったターン)。ここで arm すると
|
|
1736
1789
|
// 次の stop が来ない限り busy が残る。ターン境界の arm は従来どおり state loop / bundle hook が
|
|
1737
1790
|
// 書く event ファイル経由で行う。
|
|
1738
|
-
const { sortAffecting } = ingestSessionEventForSort(sessionName, event, at, { fresh })
|
|
1791
|
+
const { sortAffecting } = ingestSessionEventForSort(sessionName, event, at, { fresh, sortable })
|
|
1739
1792
|
if (arm) {
|
|
1740
1793
|
if (event === "prompt_submit") {
|
|
1741
1794
|
readinessTracker?.arm(sessionName, { newTurn: true })
|
|
@@ -2510,6 +2563,7 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
2510
2563
|
const cwdSnapshot = ctx.claudeBridge.getCwd?.(stream_id) ?? null
|
|
2511
2564
|
if (cwdSnapshot) {
|
|
2512
2565
|
recordPromptAccepted(cwdSnapshot, "claude", acceptedAt, ctx.claudeBridge.sessions?.get(stream_id), ctx.claudeBridge)
|
|
2566
|
+
publishChatPromptSubmit(ctx, cwdSnapshot, "claude", acceptedAt)
|
|
2513
2567
|
}
|
|
2514
2568
|
const p = ctx.claudeBridge
|
|
2515
2569
|
.input({
|
|
@@ -2725,6 +2779,7 @@ export function handleCodexChatInput(msg, ctx) {
|
|
|
2725
2779
|
const cwdSnapshot = ctx.codexBridge.getCwd?.(stream_id) ?? null
|
|
2726
2780
|
if (cwdSnapshot) {
|
|
2727
2781
|
recordPromptAccepted(cwdSnapshot, "codex", acceptedAt, ctx.codexBridge.sessions?.get(stream_id), ctx.codexBridge)
|
|
2782
|
+
publishChatPromptSubmit(ctx, cwdSnapshot, "codex", acceptedAt)
|
|
2728
2783
|
}
|
|
2729
2784
|
const p = ctx.codexBridge
|
|
2730
2785
|
.input({
|
|
@@ -4583,7 +4638,7 @@ export async function dispatch(msg, ctx) {
|
|
|
4583
4638
|
bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
|
|
4584
4639
|
logger: ctx.logger,
|
|
4585
4640
|
})
|
|
4586
|
-
ctx.client.send({ type: "chat.sessions", request_id: msg.request_id, ...refreshChatSessionsSignals(snapshot, ctx.logger) })
|
|
4641
|
+
ctx.client.send({ type: "chat.sessions", request_id: msg.request_id, ...withSortAffectingAt(refreshChatSessionsSignals(snapshot, ctx.logger)) })
|
|
4587
4642
|
} catch (err) {
|
|
4588
4643
|
ctx.client.send({
|
|
4589
4644
|
type: "chat.sessions", request_id: msg.request_id, targets: [], live: [],
|