@cocorograph/hub-agent 0.8.97 → 0.8.98

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.98",
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)
@@ -2388,9 +2394,34 @@ function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
2388
2394
  },
2389
2395
  }
2390
2396
  }
2391
- const seenClaudeChatInputIds = makeSeenInputIdRegistry()
2397
+ let seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
2398
+ // テストでは処理完了後に呼び、プロセス再起動時のメモリ破棄を模擬する。
2399
+ export function resetSeenClaudeChatInputIdsForTest() {
2400
+ seenClaudeChatInputIds = makePersistentSeenInputIdRegistry(SEEN_INPUT_IDS_MAX)
2401
+ }
2392
2402
  const seenCodexChatInputIds = makeSeenInputIdRegistry()
2393
2403
 
2404
+ /**
2405
+ * 通常終了(SIGTERM・安全な再起動)の直前に、処理中の claude.input と受理済み ID の保存を待つ。
2406
+ * 保存前に exit すると、再起動後の同じ input_id の再送が Claude へ再投入されるため。
2407
+ * ディスクや bridge が止まっていても終了を無期限に延ばさないよう timeoutMs で打ち切る
2408
+ * (打ち切り・強制終了では保証しない)。reject しない。
2409
+ */
2410
+ export async function waitForClaudeInputPersistence(timeoutMs = 2_000) {
2411
+ let timer
2412
+ const timeout = new Promise((resolve) => {
2413
+ timer = setTimeout(() => resolve("timeout"), timeoutMs)
2414
+ })
2415
+ const settled = Promise.allSettled([...claudeInflightByInputId.values()])
2416
+ .then(() => seenClaudeChatInputIds.flush())
2417
+ .then(() => "flushed")
2418
+ try {
2419
+ return await Promise.race([settled, timeout])
2420
+ } finally {
2421
+ clearTimeout(timer)
2422
+ }
2423
+ }
2424
+
2394
2425
  // round4 major#1 (T06542): codex.input の **in-flight** dedup 用レジストリ (input_id -> Promise)。
2395
2426
  // `seen` は settle 後にしか登録されないため、frontend の ack watchdog (5s) が bridge の barrier/RPC
2396
2427
  // (turn/start→turn/started は数秒〜) より短く、同一 input_id が settle 前に再送されると bridge.input を
@@ -2440,7 +2471,7 @@ export function handleClaudeChatInput(msg, ctx) {
2440
2471
  }
2441
2472
  return undefined
2442
2473
  }
2443
- if (input_id && seenClaudeChatInputIds.has(input_id)) {
2474
+ if (input_id && seenClaudeChatInputIds.has(input_id, ctx.logger) && !claudeInflightByInputId.has(input_id)) {
2444
2475
  // 再送 (ack 喪失想定)。同じターンを 2 回実行しないよう sendMessage を
2445
2476
  // 呼び直さず ack のみ返す。
2446
2477
  ctx.logger?.info?.({ stream_id, input_id }, "claude.input dedup (ack only)")
@@ -2491,10 +2522,10 @@ export function handleClaudeChatInput(msg, ctx) {
2491
2522
  // 確認前キャンセルを可能にする)。
2492
2523
  input_id,
2493
2524
  })
2494
- .then((result) => {
2525
+ .then(async (result) => {
2495
2526
  if (!input_id) return
2496
2527
  if (result?.ok) {
2497
- seenClaudeChatInputIds.remember(input_id)
2528
+ await seenClaudeChatInputIds.remember(input_id, ctx.logger)
2498
2529
  ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
2499
2530
  } else {
2500
2531
  ctx.client.send({
@@ -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
+ }