@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 +2 -2
- package/src/main.mjs +102 -16
- package/src/seen-input-id-store.mjs +66 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocorograph/hub-agent",
|
|
3
|
-
"version": "0.8.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|