@cocorograph/hub-agent 0.8.21 → 0.8.23
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 +1 -1
- package/src/claude-md-render.mjs +222 -0
- package/src/claude-md-sync.mjs +545 -136
- package/src/claude-md.mjs +272 -250
- package/src/codex-stream-bridge.mjs +49 -40
- package/src/hub-request-deadline.mjs +273 -0
- package/src/sibling-inject.mjs +149 -15
- package/src/tmux.mjs +55 -34
|
@@ -1900,22 +1900,14 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1900
1900
|
* busy: boolean, detached: boolean, idle_ms: number}>}
|
|
1901
1901
|
*/
|
|
1902
1902
|
describeLiveSessions(cwd) {
|
|
1903
|
-
const now = Date.now()
|
|
1904
1903
|
const out = []
|
|
1905
1904
|
// 案B: sessions は多対一なので distinct session で走査する(購読端末数ぶん重複させない)。
|
|
1905
|
+
// ⚠️ 記述子の組み立ては **_describe に一本化** する(claude 版が describeLiveSessions →
|
|
1906
|
+
// _describeCandidate へ寄せているのと同じ構造)。ここで別に組み立てると、片方だけ直した
|
|
1907
|
+
// ときに「一覧に出るフィールド」と「配送結果・候補一覧に出るフィールド」が無言でずれる。
|
|
1906
1908
|
for (const session of this._liveSessions()) {
|
|
1907
1909
|
if (cwd && session.cwd !== cwd) continue
|
|
1908
|
-
out.push(
|
|
1909
|
-
kind: "codex",
|
|
1910
|
-
cwd: session.cwd,
|
|
1911
|
-
stream_id: session.stream_id,
|
|
1912
|
-
thread_id: session.threadId || null,
|
|
1913
|
-
busy: !!session.activeTurnId,
|
|
1914
|
-
detached: !!session.detachRequested,
|
|
1915
|
-
// idle_ms は「新しさ」の指標(一覧の並び・宛先選定に使う)なので、閲覧 keepalive で
|
|
1916
|
-
// 動く lastActivityMs ではなく **会話ターン境界時刻 lastTurnAtMs** を基準にする。
|
|
1917
|
-
idle_ms: now - (session.lastTurnAtMs ?? session.lastActivityMs ?? now),
|
|
1918
|
-
})
|
|
1910
|
+
out.push(this._describe(session))
|
|
1919
1911
|
}
|
|
1920
1912
|
return out
|
|
1921
1913
|
}
|
|
@@ -1923,7 +1915,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1923
1915
|
/**
|
|
1924
1916
|
* 指定 cwd の「権威セッション」を返す(T06407 増分2, axis2 の単一正本)。同一 cwd に複数
|
|
1925
1917
|
* ライブ codex セッションがあるとき、**最終活性が最新のもの**(=いまユーザーが使っている
|
|
1926
|
-
*
|
|
1918
|
+
* 会話)を選ぶ。⚠️ 表示系(context% の収束)専用の規則であり、**配送(sendToCwd)はこの
|
|
1919
|
+
* 推測を使わない**(曖昧なら配送せず ambiguous を返す)。誤った表示は次 tick で直るが、
|
|
1920
|
+
* 誤配送は取り返しがつかないため、両者の判定を意図的に分けてある。
|
|
1927
1921
|
*
|
|
1928
1922
|
* ⚠️ context%(ドーナツ)は thread 固有の値なので「どのセッションから読んでも同じ」という
|
|
1929
1923
|
* 旧 rate-limit 前提(先着 1 件で十分)は成り立たない。state loop(threadIdForCwd 経由)と
|
|
@@ -1975,19 +1969,15 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1975
1969
|
* 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
|
|
1976
1970
|
* 2026-07-04)。Claude 版 `sendToCwd` の対。
|
|
1977
1971
|
*
|
|
1978
|
-
* ⚠️
|
|
1979
|
-
*
|
|
1980
|
-
*
|
|
1981
|
-
* ゾンビセッションへ注入して「ok なのに画面に出ない」事故が起きた (2026-07-17)。
|
|
1982
|
-
* claude-stream-bridge.mjs の同名メソッドと同じ選択規則に揃えてある。
|
|
1972
|
+
* ⚠️ 宛先解決: 候補を **attached (前面=detach 未要求) 優先** の層に分け、層内が 1 本のときだけ
|
|
1973
|
+
* 配送する。層内が 2 本以上なら推測せず `{status:"ambiguous", candidates}` を返して配送しない
|
|
1974
|
+
* (fail-safe)。claude-stream-bridge.mjs の同名メソッドと同じ判定規則である。
|
|
1983
1975
|
*
|
|
1984
1976
|
* ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
|
|
1985
1977
|
* src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
|
|
1986
1978
|
*
|
|
1987
1979
|
* ⚠️ 返り値 (2026-07-23, 統一再設計 1-1): claude 版と契約を揃えるため boolean から status
|
|
1988
|
-
* オブジェクトへ変更した。
|
|
1989
|
-
* を見るため claude のような undercount 由来のサイレント誤配送は構造的に起きず、宛先選定は
|
|
1990
|
-
* 従来どおり「会話ターン境界時刻が最新」のヒューリスティックを維持する (挙動不変)。
|
|
1980
|
+
* オブジェクトへ変更した。
|
|
1991
1981
|
*
|
|
1992
1982
|
* @param {string} cwd
|
|
1993
1983
|
* @param {string} text
|
|
@@ -1995,6 +1985,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1995
1985
|
* {status:"delivered", session:object, delivery_id:string, accepted_via:string}
|
|
1996
1986
|
* | {status:"delivery_unconfirmed", session:object, delivery_id:string, accepted_via:string, reason:string}
|
|
1997
1987
|
* | {status:"delivery_failed", session:object, delivery_id:string, reason:string}
|
|
1988
|
+
* | {status:"ambiguous", candidates:object[]}
|
|
1998
1989
|
* | {status:"not_found"}
|
|
1999
1990
|
* >}
|
|
2000
1991
|
*/
|
|
@@ -2005,27 +1996,30 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2005
1996
|
// attached (前面=detach 未要求) 優先 (2026-07-23 統一再設計 1-1, cross-review 指摘4):
|
|
2006
1997
|
// detach 済み warm セッションが lastTurnAtMs 上たまたま新しくても、attached な現行セッションが
|
|
2007
1998
|
// あればそちらへ配送する (browser を離れた/畳まれかけの旧会話 ghost への誤配送を防ぐ)。
|
|
2008
|
-
// claude と違い codex は同一 cwd に複数 attached が併存し得る (別スレッドの新会話 / ターン
|
|
2009
|
-
// 実行中) ため fail-safe ambiguous にはせず、tier 内は会話ターン境界時刻が最新のものを選ぶ
|
|
2010
|
-
// (閲覧 keepalive で動く lastActivityMs は使わない = 見ているだけの古い会話が本命を追い越さない)。
|
|
2011
1999
|
const attached = cands.filter((s) => !s.detachRequested)
|
|
2012
2000
|
const tier = attached.length ? attached : cands
|
|
2013
|
-
|
|
2014
|
-
let bestAt = -1
|
|
2015
|
-
for (const session of tier) {
|
|
2016
|
-
const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
|
|
2017
|
-
if (at >= bestAt) {
|
|
2018
|
-
bestAt = at
|
|
2019
|
-
best = session
|
|
2020
|
-
}
|
|
2021
|
-
}
|
|
2022
|
-
if (!best) {
|
|
2001
|
+
if (!tier.length) {
|
|
2023
2002
|
this.logger?.warn?.(
|
|
2024
2003
|
{ cwd, live_sessions: this.describeLiveSessions() },
|
|
2025
2004
|
"codex sendToCwd: no live session for cwd (ask-sibling will fail)",
|
|
2026
2005
|
)
|
|
2027
2006
|
return { status: "not_found" }
|
|
2028
2007
|
}
|
|
2008
|
+
// 層内が 2 本以上なら **推測せず配送しない** (fail-safe。claude 版 sendToCwd と同じ判定):
|
|
2009
|
+
// 旧実装は tier 内で「会話ターン境界時刻 lastTurnAtMs が最新のもの」を選んでいたが、これは
|
|
2010
|
+
// 「同一 cwd の live は実質 1 本 (2 本目は transient)」という前提の上に成り立つ推測だった。
|
|
2011
|
+
// 同一 cwd に複数 live を常態として許す方針へ変わった以上、この推測は誤配送の温床になる
|
|
2012
|
+
// (別スレッドの新会話へ配ってしまう / 見た目は ok なのに本命の画面には出ない)。曖昧なときは
|
|
2013
|
+
// 候補を返して呼び出し側に stream_id / thread_id で名指しさせる (sendToStream)。
|
|
2014
|
+
if (tier.length > 1) {
|
|
2015
|
+
const candidates = tier.map((s) => this._describe(s))
|
|
2016
|
+
this.logger?.warn?.(
|
|
2017
|
+
{ cwd, candidates },
|
|
2018
|
+
"codex sendToCwd: ambiguous target (multiple live sessions for cwd; not delivering)",
|
|
2019
|
+
)
|
|
2020
|
+
return { status: "ambiguous", candidates }
|
|
2021
|
+
}
|
|
2022
|
+
const best = tier[0]
|
|
2029
2023
|
// detach 済み warm セッションでも、ask-sibling が使うなら「新しい持ち主が付いた」とみなして
|
|
2030
2024
|
// detachRequested を解除し touch する(LRU 先頭へ)。this.input が改めて _touch するが、注入前に
|
|
2031
2025
|
// 明示しておく。ターン実行中は healthy busy = protected なので reconcile で kill されない。
|
|
@@ -2033,17 +2027,16 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2033
2027
|
this._touch(best)
|
|
2034
2028
|
return this._deliverInjection(best, text, source, {
|
|
2035
2029
|
cwd,
|
|
2036
|
-
selection: "
|
|
2030
|
+
selection: "sole-candidate",
|
|
2037
2031
|
candidates: this.describeLiveSessions(cwd).length,
|
|
2038
2032
|
})
|
|
2039
2033
|
}
|
|
2040
2034
|
|
|
2041
2035
|
/**
|
|
2042
2036
|
* 指定した id (stream_id もしくは thread_id) のライブセッションへ直接注入する
|
|
2043
|
-
* (sibling-inject の **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd
|
|
2044
|
-
*
|
|
2045
|
-
*
|
|
2046
|
-
* すれば曖昧さを回避できる。
|
|
2037
|
+
* (sibling-inject の **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd は同一 cwd に
|
|
2038
|
+
* 複数の生きた codex セッションが同居すると (推測配送せず) ambiguous を返して止まる。
|
|
2039
|
+
* 呼び出し側が describeLiveSessions で得た id を明示すれば、その曖昧さを解消して配送できる。
|
|
2047
2040
|
*
|
|
2048
2041
|
* stream_id は WS 再接続 / in-memory reattach で付け替わる (不安定) 一方、thread_id は
|
|
2049
2042
|
* 会話に固定 (安定) なので、**両方を受け付け** どちらで指定しても解決できるようにする。
|
|
@@ -2257,14 +2250,30 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2257
2250
|
}
|
|
2258
2251
|
}
|
|
2259
2252
|
|
|
2260
|
-
/**
|
|
2253
|
+
/**
|
|
2254
|
+
* セッション記述子の **単一定義** (describeLiveSessions / sendToCwd / sendToStream の
|
|
2255
|
+
* 結果整形が全てここを通る)。claude 版 `_describeCandidate` の対。
|
|
2256
|
+
*
|
|
2257
|
+
* ⚠️ なぜ 1 箇所に集約するか (2026-08-02): 同じ「セッションの記述子」が describeLiveSessions と
|
|
2258
|
+
* _describe の 2 か所で別々に定義されていると、片方だけ直したときに無言でずれる。実際
|
|
2259
|
+
* `detached` / `idle_ms` は一覧側にしか無く、sendToCwd の ambiguous 候補一覧 (= 利用者が
|
|
2260
|
+
* 宛先を選ぶために読むもの) には落ちていなかった。`idle_ms` は「どれが最近動いた会話か」を
|
|
2261
|
+
* 示す唯一の手がかりなので、候補一覧に無いと利用者は宛先を選べない。
|
|
2262
|
+
*
|
|
2263
|
+
* `idle_ms` は「新しさ」の指標(一覧の並び・宛先選定に使う)なので、閲覧 keepalive で動く
|
|
2264
|
+
* lastActivityMs ではなく **会話ターン境界時刻 lastTurnAtMs** を基準にする(見ているだけの
|
|
2265
|
+
* 古い会話が本命を追い越さないように)。
|
|
2266
|
+
*/
|
|
2261
2267
|
_describe(session) {
|
|
2268
|
+
const now = Date.now()
|
|
2262
2269
|
return {
|
|
2263
2270
|
kind: "codex",
|
|
2264
2271
|
cwd: session.cwd,
|
|
2265
2272
|
stream_id: session.stream_id,
|
|
2266
2273
|
thread_id: session.threadId || null,
|
|
2267
2274
|
busy: !!session.activeTurnId,
|
|
2275
|
+
detached: !!session.detachRequested,
|
|
2276
|
+
idle_ms: now - (session.lastTurnAtMs ?? session.lastActivityMs ?? now),
|
|
2268
2277
|
}
|
|
2269
2278
|
}
|
|
2270
2279
|
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub API 呼び出しの期限(1 往復ごと)と、セッション起動あたりの全体予算。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ ここが保証するのは次の 3 つ **だけ**。これを超える断定(「同期全体が N 秒以内」
|
|
5
|
+
* 「セッション起動が必ず N 秒で終わる」等)を書かないこと:
|
|
6
|
+
* 1. 各 HTTP 往復(headers 受信 〜 body 消費)は `HUB_REQUEST_TIMEOUT_MS` で打ち切る
|
|
7
|
+
* 2. `ensureClaudeMd` + `syncClaudeMdWithHub` は、**予算の生成時から数えた経過時間**が
|
|
8
|
+
* `SESSION_BUDGET_MS` を超えたら打ち切る(セッション起動あたり 1 本の予算)
|
|
9
|
+
* 3. 予算は **wall-clock の期限**であり、通信の待ち時間を積み上げた総量ではない。
|
|
10
|
+
* ファイル I/O・レンダリング・イベントループの停止も残量を減らす。ただし
|
|
11
|
+
* **打ち切る対象は Hub 往復と、その後に続く同期の処理**であって、それ以外の
|
|
12
|
+
* 処理を中断はしない
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 3 を「通信の待ち時間だけを積算する」実装に変えないこと。director と
|
|
15
|
+
* repositories は 1 本の deadline を共有して**並列**に待つので待ち時間が二重計上され、
|
|
16
|
+
* かつ CPU が詰まっている間・スリープ中は残量が減らないので **上限が事実上無制限**に
|
|
17
|
+
* なる。wall-clock 上限のままにしておくことが「セッション起動を有限にする」の根拠。
|
|
18
|
+
*
|
|
19
|
+
* `src/claude-md.mjs`(初期化)と `src/claude-md-sync.mjs`(双方向同期)の両方から
|
|
20
|
+
* 使うため独立モジュールにしてある。**2 系統に複製しないこと**(片方だけ直して
|
|
21
|
+
* ドリフトする)。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Hub API **1 往復あたり**(headers 受信 〜 body 消費)に掛ける既定の期限 (ms)。
|
|
26
|
+
*
|
|
27
|
+
* 15 秒とした根拠: 短くしたい誘惑があるが、低速回線・スリープ復帰直後の**正常な**
|
|
28
|
+
* 往復を恒常的に失敗させる方が害が大きい(同期が毎回諦めると正本がドリフトし続ける)。
|
|
29
|
+
* 健全な回線での往復は 1 秒程度で完了するので、15 秒は「遅いが正常」を通しつつ
|
|
30
|
+
* 「返ってこない」を切るための余裕として置いている。
|
|
31
|
+
* ⚠️ **本文サイズを根拠に書かないこと。** CLAUDE.md 正本の大きさは案件ごとに違い、
|
|
32
|
+
* 運用のなかで増減する。具体値を書くと必ず陳腐化する(旧コメントに残っていた
|
|
33
|
+
* 「往復は本文が小さいから速い」という断定は、実データの正本サイズと矛盾していた)。
|
|
34
|
+
* ⚠️ 導入前は **無制限**(body を閉じないレスポンスで往復が永久に返らない)だった。
|
|
35
|
+
*/
|
|
36
|
+
export const HUB_REQUEST_TIMEOUT_MS = 15_000
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* セッション起動 1 回あたりの **wall-clock 予算** (ms)。予算の生成時から数えた
|
|
40
|
+
* 残り時間で、通信の待ち時間を積み上げた総量ではない。
|
|
41
|
+
*
|
|
42
|
+
* 1 往復に期限があっても、初期化と同期は **複数往復**を行うため、その本数だけ
|
|
43
|
+
* 足し算されて `createSession` が止まる時間の上限が決まらない。そこで「起動 1 回で
|
|
44
|
+
* 使ってよい wall-clock」をここで決め、超えたら **何も書かずに次回へ持ち越す**
|
|
45
|
+
* (不確定な状態で fallback 本文を書かない)。
|
|
46
|
+
* ⚠️ 打ち切る対象は **Hub 往復と、それに続く同期の処理**だけ。ファイル I/O・CPU 待ち・
|
|
47
|
+
* スリープも残量を減らすが、それらを直接中断はしない(「セッション起動全体が N 秒
|
|
48
|
+
* 以内」を保証するものではない。このモジュール冒頭の 3 が正本)。
|
|
49
|
+
* ⚠️ 往復の本数を具体的に列挙しないこと。経路が増減するたびにコメントだけが
|
|
50
|
+
* 古くなり、実在しない往復を数え続ける(過去に実際そうなった)。
|
|
51
|
+
*/
|
|
52
|
+
export const SESSION_BUDGET_MS = 30_000
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 1 往復ぶんの期限とキャンセル手段をまとめたもの。
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ `Promise.race` だけでは**負けた側(`fetch` / `res.json()` / body 読み)が裏で
|
|
58
|
+
* 走り続ける**ため、期限超過時は必ず `AbortController.abort()`(+ 呼び出し側での
|
|
59
|
+
* `reader.cancel()`)を行い、正常終了時は `clear()` でタイマーを解除すること。
|
|
60
|
+
* タイマーは `unref()` して、これだけでプロセスが生き残らないようにする。
|
|
61
|
+
*
|
|
62
|
+
* @param {number} ms 期限。**0 以下なら生成時点で期限切れ**(= `guard` は即
|
|
63
|
+
* `{timedOut:true}`)。予算を使い切った後に「まだ少し待てる」と誤認しないため。
|
|
64
|
+
* @param {object} [opts]
|
|
65
|
+
* @param {AbortSignal} [opts.parentSignal] 親(=セッション全体予算)の signal。
|
|
66
|
+
* 親が abort したら子も abort し、`guard` を待っている呼び出しも解ける。
|
|
67
|
+
*/
|
|
68
|
+
export function createDeadline(ms, { parentSignal = null } = {}) {
|
|
69
|
+
const controller = new AbortController()
|
|
70
|
+
let fire = null
|
|
71
|
+
const expiredPromise = new Promise((resolve) => {
|
|
72
|
+
fire = resolve
|
|
73
|
+
})
|
|
74
|
+
let fired = false
|
|
75
|
+
const expire = () => {
|
|
76
|
+
if (fired) return
|
|
77
|
+
fired = true
|
|
78
|
+
try {
|
|
79
|
+
controller.abort()
|
|
80
|
+
} catch {
|
|
81
|
+
/* noop */
|
|
82
|
+
}
|
|
83
|
+
fire()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ⚠️ `AbortSignal.any` は Node 20.3+。engines は ">=20" なので **存在確認してから**
|
|
87
|
+
// 使い、無い環境では親 signal のリスナーで子 controller を abort する。
|
|
88
|
+
const hasAbortSignalAny = typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function"
|
|
89
|
+
let signal = controller.signal
|
|
90
|
+
let detachParent = () => {}
|
|
91
|
+
if (parentSignal) {
|
|
92
|
+
if (hasAbortSignalAny) {
|
|
93
|
+
// 合成 signal を fetch に渡す(親・子どちらの abort でも中断される)。
|
|
94
|
+
try {
|
|
95
|
+
signal = AbortSignal.any([controller.signal, parentSignal])
|
|
96
|
+
} catch {
|
|
97
|
+
signal = controller.signal
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (parentSignal.aborted) {
|
|
101
|
+
// 親が既に切れている=残り 0。生成時点で期限切れにする。
|
|
102
|
+
if (!hasAbortSignalAny) expire()
|
|
103
|
+
else {
|
|
104
|
+
fired = true
|
|
105
|
+
fire()
|
|
106
|
+
}
|
|
107
|
+
} else if (typeof parentSignal.addEventListener === "function") {
|
|
108
|
+
const onParentAbort = () => {
|
|
109
|
+
// any が使える環境では合成 signal 側が既に abort 済みなので、ここでは
|
|
110
|
+
// 「待っている guard を解く」ことだけを行えばよい。使えない環境では
|
|
111
|
+
// 子 controller を abort して signal を伝播させる。
|
|
112
|
+
if (hasAbortSignalAny) {
|
|
113
|
+
if (!fired) {
|
|
114
|
+
fired = true
|
|
115
|
+
fire()
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
expire()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
parentSignal.addEventListener("abort", onParentAbort, { once: true })
|
|
122
|
+
detachParent = () => {
|
|
123
|
+
try {
|
|
124
|
+
parentSignal.removeEventListener("abort", onParentAbort)
|
|
125
|
+
} catch {
|
|
126
|
+
/* noop */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let timer = null
|
|
133
|
+
if (ms > 0) {
|
|
134
|
+
timer = setTimeout(expire, ms)
|
|
135
|
+
if (typeof timer.unref === "function") timer.unref()
|
|
136
|
+
} else {
|
|
137
|
+
// 0 以下 = 生成時点で期限切れ。タイマーを 1 tick 待たせない。
|
|
138
|
+
expire()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
get signal() {
|
|
143
|
+
return signal
|
|
144
|
+
},
|
|
145
|
+
/** 期限切れ済みか(`clear()` 後も状態は変わらない)。 */
|
|
146
|
+
expired() {
|
|
147
|
+
return fired
|
|
148
|
+
},
|
|
149
|
+
abort() {
|
|
150
|
+
try {
|
|
151
|
+
controller.abort()
|
|
152
|
+
} catch {
|
|
153
|
+
/* noop */
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
clear() {
|
|
157
|
+
if (timer) clearTimeout(timer)
|
|
158
|
+
detachParent()
|
|
159
|
+
},
|
|
160
|
+
/**
|
|
161
|
+
* `promise` を期限付きで待つ。
|
|
162
|
+
* @returns {Promise<{timedOut: boolean, value?: any, error?: any}>}
|
|
163
|
+
* 期限超過なら `{timedOut:true}`。負けた promise には必ずハンドラを付けて
|
|
164
|
+
* unhandled rejection にしない。
|
|
165
|
+
*/
|
|
166
|
+
async guard(promise) {
|
|
167
|
+
return await Promise.race([
|
|
168
|
+
promise.then(
|
|
169
|
+
(value) => ({ timedOut: false, value }),
|
|
170
|
+
(error) => ({ timedOut: false, error }),
|
|
171
|
+
),
|
|
172
|
+
expiredPromise.then(() => ({ timedOut: true })),
|
|
173
|
+
])
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* セッション起動 1 回ぶんの **全体予算**(生成時からの wall-clock 期限)。
|
|
180
|
+
*
|
|
181
|
+
* ⚠️ 「通信の待ち時間の累積」ではない。`createSessionBudget(20)` を作って HTTP を
|
|
182
|
+
* 一度も呼ばずに 20ms 超待つと `expired()` は `true` になる。ファイル I/O・呼び出し元の
|
|
183
|
+
* 処理時間も残量を減らす(このモジュール冒頭の 3 を参照)。
|
|
184
|
+
*
|
|
185
|
+
* 使い方(呼び出し元は 1 本だけ作り、`finally` で必ず `clear()` する):
|
|
186
|
+
* const budget = createSessionBudget()
|
|
187
|
+
* try { await ensureClaudeMd({ ..., budget }); await syncClaudeMdWithHub({ ..., budget }) }
|
|
188
|
+
* finally { budget.clear() }
|
|
189
|
+
*
|
|
190
|
+
* 予算が尽きたら **書き込みを行わずに諦める**(`ensureClaudeMd` は `deferred`、
|
|
191
|
+
* `syncClaudeMdWithHub` は `skipped`)。どちらもセッション起動は止めない。
|
|
192
|
+
*/
|
|
193
|
+
export function createSessionBudget(ms = SESSION_BUDGET_MS) {
|
|
194
|
+
const controller = new AbortController()
|
|
195
|
+
const budgetMs = Math.max(0, ms)
|
|
196
|
+
const deadlineAt = Date.now() + budgetMs
|
|
197
|
+
const children = new Set()
|
|
198
|
+
|
|
199
|
+
const abortAll = () => {
|
|
200
|
+
try {
|
|
201
|
+
controller.abort()
|
|
202
|
+
} catch {
|
|
203
|
+
/* noop */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let timer = null
|
|
207
|
+
if (budgetMs > 0) {
|
|
208
|
+
timer = setTimeout(abortAll, budgetMs)
|
|
209
|
+
if (typeof timer.unref === "function") timer.unref()
|
|
210
|
+
} else {
|
|
211
|
+
abortAll()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const remainingMs = () => Math.max(0, deadlineAt - Date.now())
|
|
215
|
+
const expired = () => controller.signal.aborted || remainingMs() <= 0
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
signal: controller.signal,
|
|
219
|
+
remainingMs,
|
|
220
|
+
expired,
|
|
221
|
+
/**
|
|
222
|
+
* 1 往復ぶんの子 deadline を作る。期限は「1 往復の上限」と「予算の残り」の小さい方。
|
|
223
|
+
* 残りが 0 なら **生成時点で期限切れ** の子を返す(待たせない)。
|
|
224
|
+
*/
|
|
225
|
+
childDeadline(perRequestMs = HUB_REQUEST_TIMEOUT_MS) {
|
|
226
|
+
const remaining = remainingMs()
|
|
227
|
+
const child = createDeadline(
|
|
228
|
+
expired() ? 0 : Math.min(perRequestMs, remaining),
|
|
229
|
+
{ parentSignal: controller.signal },
|
|
230
|
+
)
|
|
231
|
+
children.add(child)
|
|
232
|
+
return child
|
|
233
|
+
},
|
|
234
|
+
/**
|
|
235
|
+
* 予算の後片付け。
|
|
236
|
+
*
|
|
237
|
+
* ⚠️ **子 deadline を abort してから** タイマーを外すこと。`clear()` は
|
|
238
|
+
* タイマーとリスナーを外すだけで signal を abort しないため、先に clear だけ
|
|
239
|
+
* すると「非 OK で早期 return したが body を閉じない応答」のように、まだ
|
|
240
|
+
* 生きているソケットを解放する手段が誰にも残らない(実測で、`ensureClaudeMd`
|
|
241
|
+
* が返った後も 3 本のソケットが接続中のままになった)。
|
|
242
|
+
*
|
|
243
|
+
* 呼び出し元(`src/tmux.mjs`)は初期化と同期の await が両方終わった後の
|
|
244
|
+
* `finally` で呼ぶので、ここで abort しても正常な往復は殺さない。
|
|
245
|
+
*/
|
|
246
|
+
clear() {
|
|
247
|
+
if (timer) clearTimeout(timer)
|
|
248
|
+
for (const child of children) {
|
|
249
|
+
try {
|
|
250
|
+
child.abort()
|
|
251
|
+
} catch {
|
|
252
|
+
/* noop */
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
child.clear()
|
|
256
|
+
} catch {
|
|
257
|
+
/* noop */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
children.clear()
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* budget があれば子 deadline を、無ければ単独 deadline を作る。
|
|
267
|
+
*
|
|
268
|
+
* 「budget 省略時も従来どおり 1 往復の期限だけは掛かる」を 1 箇所に集約する
|
|
269
|
+
* (呼び出し側に三項演算子を散らすと、片方だけ budget を渡し忘れる)。
|
|
270
|
+
*/
|
|
271
|
+
export function acquireDeadline(budget, perRequestMs = HUB_REQUEST_TIMEOUT_MS) {
|
|
272
|
+
return budget ? budget.childDeadline(perRequestMs) : createDeadline(perRequestMs)
|
|
273
|
+
}
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -33,7 +33,20 @@
|
|
|
33
33
|
* - 全一覧: `{op:"list-all", token}` → `{ok, live_sessions}` (全 cwd・両 CLI。クロス cwd
|
|
34
34
|
* 宛先探索用、2026-07-23。cwd/target_cli_kind 不要、token のみ必須)。
|
|
35
35
|
* 処理後は必ず削除し (成否問わず)、呼び出し側が待ち受けられるよう
|
|
36
|
-
* `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint
|
|
36
|
+
* `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?,
|
|
37
|
+
* candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
|
|
38
|
+
* 「候補を実行可能な `--to` として並べてよいか」を hint の文言に依存せず判定するための boolean)。
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ 曖昧系のエラーコードは 2 つある (2026-08-02 クロスレビュー round2 major):
|
|
41
|
+
* - `ambiguous_target` … 候補を `--to` で名指しできる (解消手段がある)
|
|
42
|
+
* - `ambiguous_target_unnameable` … 名指しでは解消できない (session_id 未確定 / resume 競合)
|
|
43
|
+
* 分けている理由は **版ずれが片方向にしか効かない**のを塞ぐため。`candidates_nameable` を足す
|
|
44
|
+
* だけだと、**新しい hub-agent × 旧 CLI** の組み合わせで旧 CLI が boolean を無視し、従来の
|
|
45
|
+
* `ambiguous_target` 分岐に入って**無効な `--to` を並べる** (同一 session_id の候補 2 件に対し
|
|
46
|
+
* 同じアドレスを 2 回案内する。旧 CLI 実物へ新応答を渡して再現済み)。エラーコードを変えると
|
|
47
|
+
* 旧 CLI は未知エラーとして generic 分岐へ落ち、`--to` を出さず診断 (hint / live_sessions) だけを
|
|
48
|
+
* 表示する (実測で確認済み)。`candidates_nameable` は新 CLI が両コードを共通処理するための
|
|
49
|
+
* 判断材料として **外部契約に残す**。
|
|
37
50
|
*/
|
|
38
51
|
|
|
39
52
|
import { watch } from "node:fs"
|
|
@@ -52,6 +65,128 @@ const DELIVERY_UNCONFIRMED_HINT =
|
|
|
52
65
|
const DELIVERY_FAILED_HINT =
|
|
53
66
|
"turn RPC の受付前または受付中に失敗しました。対象セッションを確認して再送してください。"
|
|
54
67
|
|
|
68
|
+
/** 曖昧だが `--to` で名指しして解消できる (候補を実行可能な宛先として案内してよい)。 */
|
|
69
|
+
export const AMBIGUOUS_TARGET_ERROR = "ambiguous_target"
|
|
70
|
+
/** 曖昧で、かつ `--to` では解消できない (名指ししても一意にならない)。 */
|
|
71
|
+
export const AMBIGUOUS_TARGET_UNNAMEABLE_ERROR = "ambiguous_target_unnameable"
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* claude の曖昧候補が `--to <session_id>` で名指しできるか、できないなら**なぜか** (2026-08-02)。
|
|
75
|
+
*
|
|
76
|
+
* 名指しできる条件は「**候補の session_id が全て非 null かつ相異なる**」こと。破れるのは 2 つで、
|
|
77
|
+
* **待てば解決するか否かが正反対**なので理由まで返す (2026-08-02 クロスレビュー round2 minor:
|
|
78
|
+
* 両方に「Cockpit で 1 本に整理」と案内していたため、待てば直る状況で不要な操作を誘発していた):
|
|
79
|
+
* - `"unresolved"` (**null が混ざる**): `sendToCwd` は `_liveCandidatesForCwd(cwd, false)` で
|
|
80
|
+
* 候補を集めるため、session_id 未確定の fresh セッション (attach 直後〜SDK の system/init
|
|
81
|
+
* 到達前) が候補に入る。実測: `attach({cwd})` 直後の `describeLiveSessions` / `sendToCwd` の
|
|
82
|
+
* 候補は `session_id: null` を返す。null は宛先として書けないが、**初期化が終われば確定する**
|
|
83
|
+
* ので「待って `--list` で再確認」が正しい案内。
|
|
84
|
+
* - `"duplicate"` (**重複する**): 同一 session_id の別 session が併存する resume 競合
|
|
85
|
+
* (`sendToStream` が ambiguous を返す状況)。id を書いても再び複数一致し、**待っても解決しない**
|
|
86
|
+
* ので「1 本に整理してから再送」が正しい案内。
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ 両方が同時に起きたら `"duplicate"` を優先する。待てば null は確定するが重複は残るため、
|
|
89
|
+
* 「待て」と案内すると手詰まりになる (保守的に整理へ倒す)。
|
|
90
|
+
* ⚠️ 理由は **hint の文面を決めるためだけ**に使い、外部契約 (`candidates_nameable`) は boolean の
|
|
91
|
+
* ままにする。CLI 側に原因判定を持ち込むと名指し可否の判定が 2 リポジトリへ二重化する。
|
|
92
|
+
*
|
|
93
|
+
* @param {object[]} [candidates] `{session_id}` を持つ候補記述子の配列
|
|
94
|
+
* @returns {"nameable"|"unresolved"|"duplicate"|"unknown"} `unknown` = 候補が空 (理由を特定できない)
|
|
95
|
+
*/
|
|
96
|
+
function claudeCandidatesNameability(candidates) {
|
|
97
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return "unknown"
|
|
98
|
+
const seen = new Set()
|
|
99
|
+
let hasUnresolved = false
|
|
100
|
+
for (const c of candidates) {
|
|
101
|
+
const id = c?.session_id
|
|
102
|
+
if (!id) hasUnresolved = true // fresh (session_id 未確定) は宛先に書けない
|
|
103
|
+
else if (seen.has(id)) return "duplicate" // resume 競合は待っても解決しない (優先)
|
|
104
|
+
else seen.add(id)
|
|
105
|
+
}
|
|
106
|
+
return hasUnresolved ? "unresolved" : "nameable"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 曖昧エラーの **エラーコード・案内文・機械可読な判定を 1 回の呼び出しで一緒に返す** 単一定義。
|
|
111
|
+
*
|
|
112
|
+
* ⚠️ なぜ 1 箇所に集約するか (2026-08-02): 案内文は当初「明示宛先 (sendToStream) が曖昧」の
|
|
113
|
+
* 分岐にしか無く、**宛先省略 (sendToCwd) が曖昧なとき**は候補一覧だけ返して解消手段を返して
|
|
114
|
+
* いなかった。今後「1 つの作業ディレクトリに複数の会話を同時に生かす」方針へ変わり、曖昧に
|
|
115
|
+
* なる状況は今より起こりやすくなるため、利用者が手詰まりにならない案内を両分岐で共有する。
|
|
116
|
+
* ここを 2 か所に書くと、CLI 種別や解消手段が変わったときに片方だけ古くなる。
|
|
117
|
+
*
|
|
118
|
+
* ⚠️ 解消手段は **CLI 種別ではなく「候補が名指し可能か」** で決める (2026-08-02 訂正)。旧実装は
|
|
119
|
+
* 「claude は一意な宛先指定子が無いので --to では解消不能」と CLI 種別だけで断定していたが、
|
|
120
|
+
* これは claude の 2 つの曖昧を混同していた:
|
|
121
|
+
* - **明示宛先の曖昧** (`sendToStream`): 同一 session_id の別 session が複数 = resume 競合。
|
|
122
|
+
* 候補の session_id は全て同じなので、確かに --to では解消できない → Cockpit で整理する案内。
|
|
123
|
+
* - **宛先省略の曖昧** (`sendToCwd`): 同一 cwd に**別々の会話**が 2 本以上。候補の session_id は
|
|
124
|
+
* 互いに異なるので **`--to <session_id>` で名指しできる** → 名指しを案内する。ここを「解消
|
|
125
|
+
* 不能」と案内すると、実際には解決できる状況で利用者が手詰まりになる (1 つの作業ディレクトリ
|
|
126
|
+
* に複数の会話を同時に生かす方針では、これが通常起こりうる状況になる)。
|
|
127
|
+
* 例外として、宛先省略でも候補に session_id 未確定の fresh が混ざると名指しできないため、その
|
|
128
|
+
* ときは名指し以外の案内へ落とす (判定は claudeCandidatesNameability)。
|
|
129
|
+
* codex は stream_id が常に一意なので、従来どおり stream_id での名指しを案内する。
|
|
130
|
+
*
|
|
131
|
+
* ⚠️ 名指しできない理由が 2 つあり、**案内が正反対になる** (2026-08-02 round2 minor):
|
|
132
|
+
* - 未確定 (`unresolved`) → 待てば session_id が確定する → 「初期化を待って --list で再確認」
|
|
133
|
+
* - 重複 (`duplicate`) → 待っても解決しない → 「1 本に整理してから再送」
|
|
134
|
+
* 両方に「整理」と案内すると、待てば直る状況で不要な操作 (チャットの開き直し) を誘発する。
|
|
135
|
+
*
|
|
136
|
+
* ⚠️ error / hint / `candidates_nameable` を **必ず同じ呼び出しで一緒に返す** (2026-08-02 クロス
|
|
137
|
+
* レビュー major / round2 major): 呼び出し側 (配布中の `cockpit_orch.py`) は「候補を実行可能な
|
|
138
|
+
* `--to` として並べてよいか」をこの機械可読な boolean だけで分岐し、**旧 CLI** はエラーコードだけで
|
|
139
|
+
* 分岐する。**hint の日本語を CLI にパースさせない** (文言を変えた瞬間に壊れるため)。判定を
|
|
140
|
+
* 「hint 用」「フィールド用」「エラーコード用」と 3 回呼ぶ形にすると将来ずれるので、1 つの判定から
|
|
141
|
+
* 全部を組み立てて構造的に食い違えないようにしている。
|
|
142
|
+
*
|
|
143
|
+
* @param {string} cliKind 宛先 CLI ("codex" | "claude")
|
|
144
|
+
* @param {string} [targetSessionId] 明示宛先で指定された id (省略 = 宛先省略の曖昧)
|
|
145
|
+
* @param {object[]} [candidates] 曖昧になった候補一覧 (claude の名指し可否判定に使う)
|
|
146
|
+
* @returns {{error: string, hint: string, candidates_nameable: boolean}}
|
|
147
|
+
* エラーコードと、空にならない案内文と、機械可読な判定
|
|
148
|
+
*/
|
|
149
|
+
function ambiguousTargetOutcome(cliKind, targetSessionId, candidates) {
|
|
150
|
+
// 名指し可否の判定はここ 1 回だけ。error も hint もこの値から導くので三者は原理的に一致する。
|
|
151
|
+
// codex は stream_id が常に一意なので候補を見るまでもなく名指しできる。
|
|
152
|
+
const nameability = cliKind === "codex" ? "nameable" : claudeCandidatesNameability(candidates)
|
|
153
|
+
const nameable = nameability === "nameable"
|
|
154
|
+
const lead = targetSessionId
|
|
155
|
+
? cliKind === "codex"
|
|
156
|
+
? `指定した id (${targetSessionId}) はこの cwd で複数の live セッションに一致します。`
|
|
157
|
+
: `指定した session_id (${targetSessionId}) がこの cwd で複数の live セッションに一致します。`
|
|
158
|
+
: `この cwd には live な ${cliKind} セッションが複数あるため、宛先を省略すると一意に定まりません。`
|
|
159
|
+
let remedy
|
|
160
|
+
if (cliKind === "codex") {
|
|
161
|
+
remedy = `stream_id で名指ししてください (--list で確認)。`
|
|
162
|
+
} else if (nameability === "nameable") {
|
|
163
|
+
remedy =
|
|
164
|
+
`候補はそれぞれ別の会話で session_id が異なるため、session_id で名指しできます ` +
|
|
165
|
+
`(--list が表示する --to の宛先をそのまま使ってください)。`
|
|
166
|
+
} else if (nameability === "unresolved") {
|
|
167
|
+
// 待てば確定する。ここで「1 本に整理」を案内すると不要な開き直しを誘発する。
|
|
168
|
+
remedy =
|
|
169
|
+
`候補に session_id が未確定のセッション (開始直後で初期化が完了していないもの) が含まれるため、` +
|
|
170
|
+
`いま --to には書けません。初期化の完了を待ってから --list で最新の宛先を確認して再送してください。`
|
|
171
|
+
} else if (nameability === "duplicate") {
|
|
172
|
+
// 待っても解決しない。Cockpit 側で 1 本に減らす操作が要る。
|
|
173
|
+
remedy =
|
|
174
|
+
`候補の session_id が重複している (同じ会話を複数 resume した競合) ため、--to で書き直しても` +
|
|
175
|
+
`再び複数一致します。Cockpit で対象 cwd の Claude チャットを開き直して1本に整理してから` +
|
|
176
|
+
`再送してください。`
|
|
177
|
+
} else {
|
|
178
|
+
// 候補が空 = 理由を特定できない。待って直るとは断定できないので保守的に整理側へ倒す。
|
|
179
|
+
remedy =
|
|
180
|
+
`候補を特定できないため --to では一意に定まりません。--list で最新の宛先を確認し、` +
|
|
181
|
+
`解消しなければ Cockpit で対象 cwd の Claude チャットを1本に整理してから再送してください。`
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
error: nameable ? AMBIGUOUS_TARGET_ERROR : AMBIGUOUS_TARGET_UNNAMEABLE_ERROR,
|
|
185
|
+
hint: lead + remedy,
|
|
186
|
+
candidates_nameable: nameable,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
55
190
|
function deliveredStateFor(kind) {
|
|
56
191
|
return kind === "codex" ? "delivered" : "dispatched"
|
|
57
192
|
}
|
|
@@ -238,23 +373,17 @@ export class SiblingInjectBridge {
|
|
|
238
373
|
}
|
|
239
374
|
} else if (r.status === "ambiguous") {
|
|
240
375
|
// 宛先 id が同一 cwd の複数 live セッションに一致し一意に定まらない。
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
376
|
+
// 解消手段の案内は ambiguousTargetOutcome に集約 (宛先省略側の分岐と共有する)。候補を
|
|
377
|
+
// 渡して「その id で名指しできるか」を判定させる (claude の resume 競合はできない)。
|
|
378
|
+
// ⚠️ error もこの単一判定から来る (名指し不能なら ambiguous_target_unnameable)。ここに
|
|
379
|
+
// error をベタ書きすると、判定と食い違ったコードを返して旧 CLI の分岐を誤らせる。
|
|
380
|
+
// candidates / live_sessions / hint / candidates_nameable は**落とさない** (旧 CLI が
|
|
381
|
+
// 未知エラーとして generic 分岐に落ちたとき、診断だけは読めるようにするため)。
|
|
245
382
|
result = {
|
|
246
383
|
ok: false,
|
|
247
|
-
error: "ambiguous_target",
|
|
248
384
|
candidates: r.candidates,
|
|
249
385
|
live_sessions: r.candidates,
|
|
250
|
-
|
|
251
|
-
target_cli_kind === "codex"
|
|
252
|
-
? `指定した id (${target_session_id}) はこの cwd で複数の live セッションに一致します。` +
|
|
253
|
-
`stream_id で名指ししてください (--list で確認)。`
|
|
254
|
-
: `指定した session_id (${target_session_id}) がこの cwd で複数の live セッションに ` +
|
|
255
|
-
`一致します。Claude は一意な宛先指定子が無いため --to では解消できません。Cockpit で` +
|
|
256
|
-
`対象 cwd の Claude チャットを開き直して1本に整理してから再送してください` +
|
|
257
|
-
`(通常は attach 時の重複解消で発生しません)。`,
|
|
386
|
+
...ambiguousTargetOutcome(target_cli_kind, target_session_id, r.candidates),
|
|
258
387
|
}
|
|
259
388
|
} else {
|
|
260
389
|
result = {
|
|
@@ -309,11 +438,16 @@ export class SiblingInjectBridge {
|
|
|
309
438
|
: DELIVERY_FAILED_HINT,
|
|
310
439
|
}
|
|
311
440
|
} else if (r.status === "ambiguous") {
|
|
441
|
+
// 宛先省略でも「候補一覧だけ返して終わり」にせず、明示宛先側と同じ案内を返す
|
|
442
|
+
// (CLI 種別で案内の有無が変わるのは不整合)。ここの候補は **別々の会話** なので、
|
|
443
|
+
// claude でも session_id が相異なれば --to で名指しできる (candidates を渡して判定)。
|
|
444
|
+
// error / hint / candidates_nameable は同じ判定から一緒に返る (新 CLI はこの boolean で
|
|
445
|
+
// 表示分岐し、旧 CLI は error だけを見る)。診断フィールドは落とさない。
|
|
312
446
|
result = {
|
|
313
447
|
ok: false,
|
|
314
|
-
error: "ambiguous_target",
|
|
315
448
|
candidates: r.candidates,
|
|
316
449
|
live_sessions: r.candidates,
|
|
450
|
+
...ambiguousTargetOutcome(target_cli_kind, undefined, r.candidates),
|
|
317
451
|
}
|
|
318
452
|
} else {
|
|
319
453
|
result = { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
|