@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.98",
3
+ "version": "0.8.100",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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 {{status:"delivered", session:object} | {status:"not_found"} | {status:"ambiguous", candidates:object[]}}
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
- // 連携カード種別 (Phase 2): 配送先が確定した後に記録する (ambiguous/not_found では記録しない)。
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 {{status: "delivered", session: object} | {status: "not_found"} | {status: "ambiguous", candidates: object[]}}
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
- // 連携カード種別 (Phase 2): 宛先確定後に記録 (not_found/ambiguous では記録しない)。
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
- session
3786
- .sendMessage({ role: "user", content: text })
3787
- .then((r) => {
3788
- // Phase 2 (major#3 / persist major#1): {accepted:false} は echo/started 来ないので live を
3789
- // 掃除し永続化もしない (失敗を永続化すると 30 日 stale で後続同文 human を誤カード化)。
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
- // 受理時のみ hydrate 用に永続化 (best-effort)。
3777
+ // 受理時のみ永続化する。timeout 後に受理が確定した場合も維持する。
3795
3778
  recordInjectSourcePersistent(cwd, text, source).catch(() => {})
3796
- })
3797
- .catch((err) => {
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
- "sendToStream: sendMessage threw unexpectedly",
3786
+ { cwd, session_id: session.sessionId, err: err?.message },
3787
+ "sibling sendMessage threw unexpectedly; delivery remains unconfirmed",
3802
3788
  )
3803
- })
3804
- return { status: "delivered", session: this._describeCandidate(session, attached) }
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
- const sortAffecting = computeSortAffecting(event, sawUserTurnByName.get(name) === true)
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
- const data = JSON.parse(text)
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: [],