@cocorograph/hub-agent 0.8.98 → 0.8.99

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/main.mjs +67 -12
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.99",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
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: [],