@cocorograph/hub-agent 0.8.97 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.97",
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",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "start": "node bin/hub-agent.mjs start",
21
- "test": "node --test --test-concurrency=1 test/*.test.mjs",
21
+ "test": "node --import ./test/setup-env.mjs --test --test-concurrency=1 test/*.test.mjs",
22
22
  "check:publish-on-main": "node scripts/check-publish-on-main.mjs",
23
23
  "prepublishOnly": "node scripts/check-publish-on-main.mjs && npm test"
24
24
  },
package/src/main.mjs CHANGED
@@ -50,6 +50,7 @@ import { WsClient } from "./ws-client.mjs"
50
50
  import { DispatchScheduler } from "./dispatch-scheduler.mjs"
51
51
  import { noteRestoreStop, restoreSessions, sendBridgeMessage } from "./session-restore.mjs"
52
52
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
53
+ import { makePersistentSeenInputIdRegistry } from "./seen-input-id-store.mjs"
53
54
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
54
55
  import { installClaudeDisplayMask } from "./claude-display-mask.mjs"
55
56
  import { CodexMcpControl } from "./codex-mcp-control.mjs"
@@ -1179,6 +1180,11 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1179
1180
  restartCoordinator?.stop?.()
1180
1181
  claudeBridge?.shutdown?.()
1181
1182
  codexBridge?.shutdown?.()
1183
+ // 受理済み input_id の保存を待ってから exit する(再起動後の再送で二重実行しないため)。
1184
+ const persistence = await waitForClaudeInputPersistence()
1185
+ if (persistence === "timeout") {
1186
+ logger.warn({ signal }, "claude input_id の保存待ちが上限に達したため終了する")
1187
+ }
1182
1188
  client.stop()
1183
1189
  // 0.6.2 fix: watchdog を解放して event loop を抜けられるようにする
1184
1190
  clearInterval(keepaliveTimer)
@@ -1405,7 +1411,7 @@ export function updateSawUserTurn(name, event) {
1405
1411
  * @param {number} at epoch ms
1406
1412
  * @returns {{ sortAffecting: boolean, sortAffectingAt: number|undefined, stale: boolean }}
1407
1413
  */
1408
- export function ingestSessionEventForSort(name, event, at, { fresh = false } = {}) {
1414
+ export function ingestSessionEventForSort(name, event, at, { fresh = false, sortable = true } = {}) {
1409
1415
  const prevAt = lastEventAtByName.get(name) ?? 0
1410
1416
  // stale (この session で処理済みの at より古い) read は状態を汚さない。arm や status は
1411
1417
  // 呼び出し側で従来通り処理してよいが、sort へは一切効かせない。
@@ -1431,7 +1437,9 @@ export function ingestSessionEventForSort(name, event, at, { fresh = false } = {
1431
1437
  }
1432
1438
  if (typeof at === "number") lastEventAtByName.set(name, Math.max(prevAt, at))
1433
1439
  updateSawUserTurn(name, event)
1434
- 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)
1435
1443
  // sort_affecting=true で前進したときだけ sort 時刻を更新し、sidecar に永続化する (major#1)。
1436
1444
  // event ファイルは揮発 (最新イベントで上書き) のため、再起動後に「最後の実ターン時刻」を
1437
1445
  // 復元できるよう別ファイルに残す。冪等な再取込 (at 同値) では書かない。
@@ -1451,6 +1459,26 @@ export function getSortAffectingAt(name) {
1451
1459
  return lastSortAffectingAtByName.get(name)
1452
1460
  }
1453
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
+
1454
1482
  // sort 時刻 sidecar の接尾辞。`.json` にしない (readAllSessionEvents / pushFile が
1455
1483
  // `.endsWith(".json")` で event ファイルと誤認しないように)。
1456
1484
  const SORT_AT_SUFFIX = ".sortat"
@@ -1633,6 +1661,24 @@ async function readAllSessionEvents() {
1633
1661
  return out
1634
1662
  }
1635
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
+
1636
1682
  /**
1637
1683
  * `/tmp/cockpit_session_events/<tmux_session>.json` を fs.watch して
1638
1684
  * UserPromptSubmit / Stop の event を `session.event` 型で Hub に push する。
@@ -1662,13 +1708,7 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
1662
1708
  const full = path.join(SESSION_EVENTS_DIR, filename)
1663
1709
  try {
1664
1710
  const text = await readFile(full, "utf-8")
1665
- const data = JSON.parse(text)
1666
- if (!data || typeof data.event !== "string") return
1667
- const at = typeof data.at === "number" ? data.at : Date.now()
1668
- // チャットの行は state loop が返答付きの stop を送る。ここでも送ると Hub が完了通知を
1669
- // 2 通 (定型文だけ + 返答付き) 出すため、送信だけ省く (並び順・readiness は取り込む)。
1670
- const send = !(data.event === "stop" && chatStopNotifyingSessionNames.has(sessionName))
1671
- await publishSessionEvent({ client, readinessTracker, logger }, sessionName, data.event, at, { send })
1711
+ await publishHookSessionEvent({ client, readinessTracker, logger }, sessionName, JSON.parse(text))
1672
1712
  } catch {
1673
1713
  // 一時欠如 / parse 失敗等は無視 (次の変更で読み直す)
1674
1714
  }
@@ -1716,20 +1756,39 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
1716
1756
  */
1717
1757
  export const chatStopNotifyingSessionNames = new Set()
1718
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
+
1719
1778
  /** session-event をプロセス内で取り込み、並び順を更新して WS push する。 */
1720
1779
  export async function publishSessionEvent(
1721
1780
  { client, readinessTracker, logger },
1722
1781
  sessionName,
1723
1782
  event,
1724
1783
  at,
1725
- { fresh = false, arm = !fresh, text = "", send = true } = {},
1784
+ { fresh = false, arm = !fresh, text = "", send = true, sortable = true } = {},
1726
1785
  ) {
1727
1786
  // readiness (proc 内省の ready 判定) を動かすのはファイル監視経路だけ。直接 publish (fresh) は
1728
1787
  // 「人間が送った」事実を並び順へ流すだけで、ターンの開始を意味しない (bridge が拒否すれば
1729
1788
  // ターンは始まらないし、走査が逆順に完了した後着の古い方は既に終わったターン)。ここで arm すると
1730
1789
  // 次の stop が来ない限り busy が残る。ターン境界の arm は従来どおり state loop / bundle hook が
1731
1790
  // 書く event ファイル経由で行う。
1732
- const { sortAffecting } = ingestSessionEventForSort(sessionName, event, at, { fresh })
1791
+ const { sortAffecting } = ingestSessionEventForSort(sessionName, event, at, { fresh, sortable })
1733
1792
  if (arm) {
1734
1793
  if (event === "prompt_submit") {
1735
1794
  readinessTracker?.arm(sessionName, { newTurn: true })
@@ -2388,9 +2447,34 @@ function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
2388
2447
  },
2389
2448
  }
2390
2449
  }
2391
- const seenClaudeChatInputIds = makeSeenInputIdRegistry()
2450
+ let seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
2451
+ // テストでは処理完了後に呼び、プロセス再起動時のメモリ破棄を模擬する。
2452
+ export function resetSeenClaudeChatInputIdsForTest() {
2453
+ seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
2454
+ }
2392
2455
  const seenCodexChatInputIds = makeSeenInputIdRegistry()
2393
2456
 
2457
+ /**
2458
+ * 通常終了(SIGTERM・安全な再起動)の直前に、処理中の claude.input と受理済み ID の保存を待つ。
2459
+ * 保存前に exit すると、再起動後の同じ input_id の再送が Claude へ再投入されるため。
2460
+ * ディスクや bridge が止まっていても終了を無期限に延ばさないよう timeoutMs で打ち切る
2461
+ * (打ち切り・強制終了では保証しない)。reject しない。
2462
+ */
2463
+ export async function waitForClaudeInputPersistence(timeoutMs = 2_000) {
2464
+ let timer
2465
+ const timeout = new Promise((resolve) => {
2466
+ timer = setTimeout(() => resolve("timeout"), timeoutMs)
2467
+ })
2468
+ const settled = Promise.allSettled([...claudeInflightByInputId.values()])
2469
+ .then(() => seenClaudeChatInputIds.flush())
2470
+ .then(() => "flushed")
2471
+ try {
2472
+ return await Promise.race([settled, timeout])
2473
+ } finally {
2474
+ clearTimeout(timer)
2475
+ }
2476
+ }
2477
+
2394
2478
  // round4 major#1 (T06542): codex.input の **in-flight** dedup 用レジストリ (input_id -> Promise)。
2395
2479
  // `seen` は settle 後にしか登録されないため、frontend の ack watchdog (5s) が bridge の barrier/RPC
2396
2480
  // (turn/start→turn/started は数秒〜) より短く、同一 input_id が settle 前に再送されると bridge.input を
@@ -2440,7 +2524,7 @@ export function handleClaudeChatInput(msg, ctx) {
2440
2524
  }
2441
2525
  return undefined
2442
2526
  }
2443
- if (input_id && seenClaudeChatInputIds.has(input_id)) {
2527
+ if (input_id && seenClaudeChatInputIds.has(input_id, ctx.logger) && !claudeInflightByInputId.has(input_id)) {
2444
2528
  // 再送 (ack 喪失想定)。同じターンを 2 回実行しないよう sendMessage を
2445
2529
  // 呼び直さず ack のみ返す。
2446
2530
  ctx.logger?.info?.({ stream_id, input_id }, "claude.input dedup (ack only)")
@@ -2479,6 +2563,7 @@ export function handleClaudeChatInput(msg, ctx) {
2479
2563
  const cwdSnapshot = ctx.claudeBridge.getCwd?.(stream_id) ?? null
2480
2564
  if (cwdSnapshot) {
2481
2565
  recordPromptAccepted(cwdSnapshot, "claude", acceptedAt, ctx.claudeBridge.sessions?.get(stream_id), ctx.claudeBridge)
2566
+ publishChatPromptSubmit(ctx, cwdSnapshot, "claude", acceptedAt)
2482
2567
  }
2483
2568
  const p = ctx.claudeBridge
2484
2569
  .input({
@@ -2491,10 +2576,10 @@ export function handleClaudeChatInput(msg, ctx) {
2491
2576
  // 確認前キャンセルを可能にする)。
2492
2577
  input_id,
2493
2578
  })
2494
- .then((result) => {
2579
+ .then(async (result) => {
2495
2580
  if (!input_id) return
2496
2581
  if (result?.ok) {
2497
- seenClaudeChatInputIds.remember(input_id)
2582
+ await seenClaudeChatInputIds.remember(input_id, ctx.logger)
2498
2583
  ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
2499
2584
  } else {
2500
2585
  ctx.client.send({
@@ -2694,6 +2779,7 @@ export function handleCodexChatInput(msg, ctx) {
2694
2779
  const cwdSnapshot = ctx.codexBridge.getCwd?.(stream_id) ?? null
2695
2780
  if (cwdSnapshot) {
2696
2781
  recordPromptAccepted(cwdSnapshot, "codex", acceptedAt, ctx.codexBridge.sessions?.get(stream_id), ctx.codexBridge)
2782
+ publishChatPromptSubmit(ctx, cwdSnapshot, "codex", acceptedAt)
2697
2783
  }
2698
2784
  const p = ctx.codexBridge
2699
2785
  .input({
@@ -4552,7 +4638,7 @@ export async function dispatch(msg, ctx) {
4552
4638
  bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
4553
4639
  logger: ctx.logger,
4554
4640
  })
4555
- 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)) })
4556
4642
  } catch (err) {
4557
4643
  ctx.client.send({
4558
4644
  type: "chat.sessions", request_id: msg.request_id, targets: [], live: [],
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { mkdir, writeFile, rename, unlink } from "node:fs/promises"
3
+ import { randomUUID } from "node:crypto"
4
+ import path from "node:path"
5
+ import { paths } from "./config.mjs"
6
+
7
+ /** Claude の受理済み ID。初回だけ同期読み込みし、受付の同期的な順序を保つ。 */
8
+ export function makePersistentSeenInputIdRegistry(max = 1_000) {
9
+ const seen = new Set()
10
+ let storePath
11
+ let writeChain = Promise.resolve()
12
+
13
+ function trim() {
14
+ while (seen.size > max) seen.delete(seen.values().next().value)
15
+ }
16
+
17
+ function ensureLoaded(logger) {
18
+ if (storePath !== undefined) return
19
+ storePath = process.env.HUB_CLAUDE_SEEN_INPUT_IDS_PATH ||
20
+ path.join(paths.configDir, "claude-seen-input-ids.json")
21
+ try {
22
+ const ids = JSON.parse(readFileSync(storePath, "utf8"))
23
+ if (!Array.isArray(ids)) throw new Error("expected an input_id array")
24
+ if (ids.some((id) => typeof id !== "string")) {
25
+ logger?.warn?.("claude input_id store contains invalid entries")
26
+ }
27
+ for (const id of ids) if (typeof id === "string") seen.add(id)
28
+ trim()
29
+ } catch (err) {
30
+ if (err.code !== "ENOENT") {
31
+ logger?.warn?.({ err: err.message }, "claude input_id store load failed")
32
+ }
33
+ }
34
+ }
35
+
36
+ return {
37
+ has(id, logger) {
38
+ ensureLoaded(logger)
39
+ return seen.has(id)
40
+ },
41
+ remember(id, logger) {
42
+ ensureLoaded(logger)
43
+ seen.add(id)
44
+ trim()
45
+ const snapshot = JSON.stringify([...seen])
46
+ // rename まで直列化し、古い snapshot が新しい記録を消さないようにする。
47
+ writeChain = writeChain.then(async () => {
48
+ const tmp = `${storePath}.${process.pid}.${randomUUID()}.tmp`
49
+ try {
50
+ await mkdir(path.dirname(storePath), { recursive: true, mode: 0o700 })
51
+ await writeFile(tmp, snapshot, { mode: 0o600, flag: "wx" })
52
+ await rename(tmp, storePath)
53
+ } catch (err) {
54
+ logger?.warn?.({ err: err.message }, "claude input_id store write failed")
55
+ } finally {
56
+ await unlink(tmp).catch(() => {})
57
+ }
58
+ })
59
+ return writeChain
60
+ },
61
+ /** 予約済みの保存がすべて終わるまで待つ(失敗は内部で warn 済みなので reject しない)。 */
62
+ flush() {
63
+ return writeChain
64
+ },
65
+ }
66
+ }