@cocorograph/hub-agent 0.8.59 → 0.8.60
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/scripts/install.sh +79 -60
- package/src/claude-history.mjs +202 -2
- package/src/claude-stream-bridge.mjs +82 -3
package/package.json
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -494,28 +494,40 @@ ensure_node_tls_works() {
|
|
|
494
494
|
exit 1
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
497
|
+
# パッケージマネージャ分岐の共通処理。失敗は戻り値で返し、exit するかは呼び出し側が決める。
|
|
498
|
+
# 1 … 導入コマンドが失敗した / 2 … 対応するパッケージマネージャが無い
|
|
499
|
+
# ⚠️ display_cmd は復旧案内に出す「入れたい実行コマンド」。パッケージ名と一致しない
|
|
500
|
+
# ことがある (pacman の python パッケージ / 検証対象は python3 コマンド)。
|
|
501
|
+
_pm_install() {
|
|
502
|
+
local brew_pkg="$1"
|
|
503
|
+
local apt_pkg="$2"
|
|
504
|
+
local pacman_pkg="${3:-$apt_pkg}"
|
|
505
|
+
local display_cmd="${4:-$brew_pkg}"
|
|
503
506
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
504
507
|
# brew install は transient (network / hash mismatch / pour 失敗) を retry で吸収
|
|
505
|
-
retry 3 brew install "$brew_pkg"
|
|
508
|
+
retry 3 brew install "$brew_pkg" || return 1
|
|
506
509
|
elif present apt-get; then
|
|
507
|
-
retry 3 sudo apt-get update -y
|
|
508
|
-
retry 3 sudo apt-get install -y "$apt_pkg"
|
|
510
|
+
retry 3 sudo apt-get update -y || return 1
|
|
511
|
+
retry 3 sudo apt-get install -y "$apt_pkg" || return 1
|
|
509
512
|
elif present dnf; then
|
|
510
|
-
retry 3 sudo dnf install -y "$apt_pkg"
|
|
513
|
+
retry 3 sudo dnf install -y "$apt_pkg" || return 1
|
|
511
514
|
elif present pacman; then
|
|
512
|
-
retry 3 sudo pacman -S --noconfirm "$
|
|
515
|
+
retry 3 sudo pacman -S --noconfirm "$pacman_pkg" || return 1
|
|
513
516
|
else
|
|
514
|
-
color_err "対応するパッケージマネージャ (brew/apt/dnf/pacman) が見つかりません。$
|
|
515
|
-
|
|
517
|
+
color_err "対応するパッケージマネージャ (brew/apt/dnf/pacman) が見つかりません。$display_cmd を手動で install してください"
|
|
518
|
+
return 2
|
|
516
519
|
fi
|
|
517
520
|
}
|
|
518
521
|
|
|
522
|
+
ensure_pkg() {
|
|
523
|
+
local cmd="$1"
|
|
524
|
+
local brew_pkg="$2"
|
|
525
|
+
local apt_pkg="${3:-$brew_pkg}"
|
|
526
|
+
if present "$cmd"; then color_ok "$cmd already installed"; return 0; fi
|
|
527
|
+
color_step "$cmd をインストール"
|
|
528
|
+
_pm_install "$brew_pkg" "$apt_pkg" "" "$cmd" || exit 1
|
|
529
|
+
}
|
|
530
|
+
|
|
519
531
|
# macOS で brew 経由で Active LTS の node (NODE_DEFAULT_BREW_FORMULA) を導入/切替する。
|
|
520
532
|
# 既存の無印 node が link されていれば unlink してから新 formula を link --overwrite --force。
|
|
521
533
|
_install_node_lts_brew() {
|
|
@@ -590,22 +602,13 @@ ensure_python3() {
|
|
|
590
602
|
fi
|
|
591
603
|
|
|
592
604
|
color_step "python3 をインストール"
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
605
|
+
# macOS で python3 が動かないのは、ほぼ Xcode CLT 未導入(/usr/bin/python3 がスタブ)。
|
|
606
|
+
# brew 経路が失敗したら、set -e で黙って落ちる前に実際の復旧手順を出す。
|
|
607
|
+
# pacman のパッケージ名は python3 でなく python。案内に出すコマンド名は python3。
|
|
608
|
+
if ! _pm_install python python3 python python3; then
|
|
609
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
597
610
|
color_err "python3 を導入できません。\`xcode-select --install\` を実行してから再実行してください"
|
|
598
|
-
exit 1
|
|
599
611
|
fi
|
|
600
|
-
elif present apt-get; then
|
|
601
|
-
retry 3 sudo apt-get update -y
|
|
602
|
-
retry 3 sudo apt-get install -y python3
|
|
603
|
-
elif present dnf; then
|
|
604
|
-
retry 3 sudo dnf install -y python3
|
|
605
|
-
elif present pacman; then
|
|
606
|
-
retry 3 sudo pacman -S --noconfirm python
|
|
607
|
-
else
|
|
608
|
-
color_err "対応するパッケージマネージャ (brew/apt/dnf/pacman) が見つかりません。python3 を手動で install してください"
|
|
609
612
|
exit 1
|
|
610
613
|
fi
|
|
611
614
|
hash -r 2>/dev/null || true
|
|
@@ -749,51 +752,67 @@ ensure_global_install() {
|
|
|
749
752
|
color_ok "hub-agent $(hub-agent --version)"
|
|
750
753
|
}
|
|
751
754
|
|
|
752
|
-
#
|
|
753
|
-
#
|
|
754
|
-
#
|
|
755
|
-
|
|
756
|
-
|
|
755
|
+
# npm 配信の CLI を導入/upgrade する共通処理。
|
|
756
|
+
# required=1 … 新規導入の失敗を非 0 で呼び出し側へ返す。main から素の
|
|
757
|
+
# コマンドとして呼べば set -e でセットアップ全体が止まる
|
|
758
|
+
# (この関数自身は exit しないので、条件式から呼べば拾える)
|
|
759
|
+
# required=0 … 新規導入が失敗しても警告のみでセットアップを継続する
|
|
760
|
+
# 上記以外 … 契約違反として 2 を返す (タイプミスを optional へ倒さない)
|
|
761
|
+
# ⚠️ fail_hint は「導入できなかったとき何が使えなくなるか」を呼び出し側が渡す。
|
|
762
|
+
# この関数に CLI 固有の文言を書かないこと (次の CLI を足したとき嘘になる)。
|
|
763
|
+
_ensure_npm_cli() {
|
|
764
|
+
local cmd="$1"
|
|
765
|
+
local package="$2"
|
|
766
|
+
local label="$3"
|
|
767
|
+
local required="$4"
|
|
768
|
+
local fail_hint="${5:-}"
|
|
769
|
+
# ⚠️ required は 0/1 のみ受け付ける。"true" 等のタイプミスを optional 扱いへ
|
|
770
|
+
# 黙って倒すと、必須 CLI が入らないままセットアップが完了する。
|
|
771
|
+
case "$required" in
|
|
772
|
+
0|1) ;;
|
|
773
|
+
*)
|
|
774
|
+
color_err "_ensure_npm_cli: required は 0/1 のみです (受け取った値: '${required}')"
|
|
775
|
+
return 2
|
|
776
|
+
;;
|
|
777
|
+
esac
|
|
778
|
+
if present "$cmd"; then
|
|
757
779
|
local cur
|
|
758
|
-
cur=$(
|
|
759
|
-
color_step "
|
|
780
|
+
cur=$("$cmd" --version 2>/dev/null </dev/null || echo "unknown")
|
|
781
|
+
color_step "$label (現在 $cur) を最新版にアップデート"
|
|
760
782
|
# claude は Anthropic 配信。失敗しても既存版で継続 (warning のみ)。
|
|
761
|
-
retry 2 npm install -g "${
|
|
762
|
-
|| color_warn "
|
|
783
|
+
retry 2 npm install -g "${package}@latest" --force \
|
|
784
|
+
|| color_warn "$label upgrade に失敗 (既存版で継続)"
|
|
763
785
|
else
|
|
764
|
-
color_step "$
|
|
765
|
-
|
|
786
|
+
color_step "$package を install"
|
|
787
|
+
if [[ "$required" == "1" ]]; then
|
|
788
|
+
# ⚠️ 明示的に return する。条件式 (`if f; then`) の中では関数内の errexit が
|
|
789
|
+
# 抑止されるため、暗黙の set -e に頼ると必須契約が呼び出し方で消える。
|
|
790
|
+
retry 3 npm install -g "${package}@latest" --force || return 1
|
|
791
|
+
elif ! retry 3 npm install -g "${package}@latest" --force; then
|
|
792
|
+
color_warn "$label の導入に失敗しました。${fail_hint:-セットアップは継続します}"
|
|
793
|
+
color_warn " 手動で実行: npm install -g ${package}@latest"
|
|
794
|
+
fi
|
|
766
795
|
fi
|
|
767
796
|
hash -r 2>/dev/null || true
|
|
768
|
-
if present
|
|
769
|
-
color_ok "
|
|
797
|
+
if present "$cmd"; then
|
|
798
|
+
color_ok "$cmd $("$cmd" --version 2>/dev/null </dev/null || echo 'installed')"
|
|
770
799
|
else
|
|
771
|
-
color_warn "
|
|
800
|
+
color_warn "$cmd コマンドが PATH に見つかりません。新規シェルで再確認してください"
|
|
772
801
|
fi
|
|
773
802
|
}
|
|
774
803
|
|
|
804
|
+
# Claude Code CLI を同梱でセットアップ。Cockpit 側から claude を呼び出すので、
|
|
805
|
+
# 同じ npm-global prefix に入れておくとパス解決が一貫する。
|
|
806
|
+
# 既存があれば upgrade のみ(破壊しない)。hub-agent のチャットに必須なので required=1。
|
|
807
|
+
ensure_claude_code() {
|
|
808
|
+
_ensure_npm_cli claude "$CLAUDE_CODE_PACKAGE" "Claude Code" 1
|
|
809
|
+
}
|
|
810
|
+
|
|
775
811
|
# Codex CLI を同梱でセットアップ。hub-agent 自体の動作には必須ではないため、
|
|
776
812
|
# 新規導入・upgrade とも失敗時は警告してセットアップを継続する。
|
|
777
813
|
ensure_codex() {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
cur=$(codex --version 2>/dev/null </dev/null || echo "unknown")
|
|
781
|
-
color_step "Codex CLI (現在 $cur) を最新版にアップデート"
|
|
782
|
-
retry 2 npm install -g "${CODEX_PACKAGE}@latest" --force \
|
|
783
|
-
|| color_warn "Codex CLI upgrade に失敗 (既存版で継続)"
|
|
784
|
-
else
|
|
785
|
-
color_step "$CODEX_PACKAGE を install"
|
|
786
|
-
if ! retry 3 npm install -g "${CODEX_PACKAGE}@latest" --force; then
|
|
787
|
-
color_warn "Codex CLI の導入に失敗しました。Codex チャット / 委譲 / 画像生成は使えません"
|
|
788
|
-
color_warn " 手動で実行: npm install -g @openai/codex@latest"
|
|
789
|
-
fi
|
|
790
|
-
fi
|
|
791
|
-
hash -r 2>/dev/null || true
|
|
792
|
-
if present codex; then
|
|
793
|
-
color_ok "codex $(codex --version 2>/dev/null </dev/null || echo 'installed')"
|
|
794
|
-
else
|
|
795
|
-
color_warn "codex コマンドが PATH に見つかりません。新規シェルで再確認してください"
|
|
796
|
-
fi
|
|
814
|
+
_ensure_npm_cli codex "$CODEX_PACKAGE" "Codex CLI" 0 \
|
|
815
|
+
"Codex チャット / 委譲 / 画像生成は使えません"
|
|
797
816
|
}
|
|
798
817
|
|
|
799
818
|
do_enroll() {
|
package/src/claude-history.mjs
CHANGED
|
@@ -169,10 +169,209 @@ async function getLastTurnAtMsCached(fileMap, filePath, mtimeMs, size) {
|
|
|
169
169
|
// round2 minor 対応)。前回値も無ければ最後の手段として mtime。
|
|
170
170
|
const extracted = await extractLastTurnAtMs(filePath, null)
|
|
171
171
|
const lastTurnAtMs = extracted ?? entry?.lastTurnAtMs ?? mtimeMs
|
|
172
|
-
fileMap.set(filePath, { mtimeMs, size, lastTurnAtMs })
|
|
172
|
+
fileMap.set(filePath, { ...entry, mtimeMs, size, lastTurnAtMs })
|
|
173
173
|
return lastTurnAtMs
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
const TITLE_SCAN_CHUNK_SIZE = 64 * 1024
|
|
177
|
+
const TITLE_FINGERPRINT_SIZE = 256
|
|
178
|
+
let titleScanBytesForTest = 0
|
|
179
|
+
let titleMaxCarryBytesForTest = 0
|
|
180
|
+
export function getTitleScanBytesForTest() {
|
|
181
|
+
return titleScanBytesForTest
|
|
182
|
+
}
|
|
183
|
+
export function resetTitleScanBytesForTest() {
|
|
184
|
+
titleScanBytesForTest = 0
|
|
185
|
+
}
|
|
186
|
+
export function getTitleMaxCarryBytesForTest() {
|
|
187
|
+
return titleMaxCarryBytesForTest
|
|
188
|
+
}
|
|
189
|
+
export function resetTitleMaxCarryBytesForTest() {
|
|
190
|
+
titleMaxCarryBytesForTest = 0
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function takeTitleFromLine(line, entry) {
|
|
194
|
+
if (!line.includes('"custom-title"') && !line.includes('"ai-title"')) return
|
|
195
|
+
let obj
|
|
196
|
+
try {
|
|
197
|
+
obj = JSON.parse(line)
|
|
198
|
+
} catch {
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
if (obj?.type === "custom-title" && typeof obj.customTitle === "string") {
|
|
202
|
+
entry.customTitle = obj.customTitle
|
|
203
|
+
} else if (obj?.type === "ai-title" && typeof obj.aiTitle === "string") {
|
|
204
|
+
entry.aiTitle = obj.aiTitle
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function titleFromEntry(entry) {
|
|
209
|
+
const customTitle = entry.customTitle?.trim().replace(/\s+/g, " ")
|
|
210
|
+
const aiTitle = entry.aiTitle?.trim().replace(/\s+/g, " ")
|
|
211
|
+
const title = customTitle || aiTitle
|
|
212
|
+
return title ? (MASK_ENABLED ? redactSecrets(title) : title) : undefined
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** jsonl の前回処理済み改行以降だけを読み、表示名を返す。
|
|
216
|
+
*
|
|
217
|
+
* 呼び出し側の stat (size / mtimeMs) は「走査が要るか」の判定にだけ使い、走査範囲と commit 値は
|
|
218
|
+
* open 後の fstat で取り直す (cross-review round2 F2: 古い stat を持った呼び出しが新しい走査の
|
|
219
|
+
* 後に到着すると、古い size まで再走査してキャッシュを巻き戻していた)。進行中の走査を待った
|
|
220
|
+
* 呼び出しは、その走査が fstat 時点の最新を commit しているので再走査せず commit 値を返す。
|
|
221
|
+
*
|
|
222
|
+
* 書き換え検知は「size 減少」と「scannedBytes 直前 256 バイトの fingerprint 不一致」で行う。
|
|
223
|
+
* ⚠️ 既知の限界 (round2 F1 で指摘・意図的に採らない): 末尾 256 バイトを保ったまま前方だけを
|
|
224
|
+
* 書き換える置換は検出できない。jsonl を書くのは Claude CLI だけで、title の変更は新しい
|
|
225
|
+
* custom-title / ai-title 行の追記 (last-wins) として現れ、既存行の置換は行わない。完全検知には
|
|
226
|
+
* 変更のたびに処理済み領域全体の再読込が要り、ハートビート経路の差分キャッシュが成立しなくなる。 */
|
|
227
|
+
async function extractTitleCached(fileMap, filePath, size, mtimeMs) {
|
|
228
|
+
let entry = fileMap.get(filePath)
|
|
229
|
+
if (!entry) {
|
|
230
|
+
entry = { scannedBytes: 0 }
|
|
231
|
+
fileMap.set(filePath, entry)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (entry.titleScanPromise) {
|
|
235
|
+
// 進行中の走査は fstat 時点の最新状態を commit する。待った側の stat が新しくても古くても、
|
|
236
|
+
// ここで再走査すると (古い stat なら) 巻き戻し、(新しい stat なら) 二重走査になるだけなので
|
|
237
|
+
// commit 値をそのまま返す。走査後に追記された分は次回の listSessions で拾う。
|
|
238
|
+
await entry.titleScanPromise
|
|
239
|
+
return titleFromEntry(fileMap.get(filePath) || entry)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (size === entry.scannedBytes && mtimeMs === entry.titleMtimeMs) {
|
|
243
|
+
return titleFromEntry(entry)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const scanPromise = (async () => {
|
|
247
|
+
const previousScannedBytes = typeof entry.scannedBytes === "number" ? entry.scannedBytes : 0
|
|
248
|
+
let scanFrom = previousScannedBytes
|
|
249
|
+
let customTitle = entry.customTitle
|
|
250
|
+
let aiTitle = entry.aiTitle
|
|
251
|
+
let fingerprint = Buffer.isBuffer(entry.titleFingerprint) ? entry.titleFingerprint : Buffer.alloc(0)
|
|
252
|
+
let skippingHugeLine = entry.titleSkippingHugeLine === true
|
|
253
|
+
let handle
|
|
254
|
+
try {
|
|
255
|
+
handle = await open(filePath, "r")
|
|
256
|
+
// 走査範囲と commit 値は呼び出し側の snapshot ではなく open した実体の fstat を使う。
|
|
257
|
+
const actualStat = await handle.stat()
|
|
258
|
+
size = actualStat.size
|
|
259
|
+
mtimeMs = actualStat.mtimeMs
|
|
260
|
+
let reset = size < previousScannedBytes
|
|
261
|
+
if (!reset && previousScannedBytes > 0) {
|
|
262
|
+
const fingerprintStart = Math.max(0, previousScannedBytes - TITLE_FINGERPRINT_SIZE)
|
|
263
|
+
const expectedLength = previousScannedBytes - fingerprintStart
|
|
264
|
+
const actual = Buffer.allocUnsafe(expectedLength)
|
|
265
|
+
const { bytesRead } = await handle.read(actual, 0, expectedLength, fingerprintStart)
|
|
266
|
+
titleScanBytesForTest += bytesRead
|
|
267
|
+
reset = bytesRead !== fingerprint.length
|
|
268
|
+
|| !actual.subarray(0, bytesRead).equals(fingerprint)
|
|
269
|
+
}
|
|
270
|
+
if (reset) {
|
|
271
|
+
scanFrom = 0
|
|
272
|
+
customTitle = undefined
|
|
273
|
+
aiTitle = undefined
|
|
274
|
+
fingerprint = Buffer.alloc(0)
|
|
275
|
+
skippingHugeLine = false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let position = scanFrom
|
|
279
|
+
let scannedBytes = scanFrom
|
|
280
|
+
let fingerprintTail = fingerprint
|
|
281
|
+
let carry = Buffer.alloc(0)
|
|
282
|
+
while (position < size) {
|
|
283
|
+
const length = Math.min(TITLE_SCAN_CHUNK_SIZE, size - position)
|
|
284
|
+
const chunk = Buffer.allocUnsafe(length)
|
|
285
|
+
const { bytesRead } = await handle.read(chunk, 0, length, position)
|
|
286
|
+
if (bytesRead === 0) break
|
|
287
|
+
titleScanBytesForTest += bytesRead
|
|
288
|
+
position += bytesRead
|
|
289
|
+
let incoming = chunk.subarray(0, bytesRead)
|
|
290
|
+
if (skippingHugeLine) {
|
|
291
|
+
const newline = incoming.indexOf(NEWLINE_BYTE)
|
|
292
|
+
if (newline < 0) {
|
|
293
|
+
scannedBytes = position
|
|
294
|
+
fingerprintTail = Buffer.concat([fingerprintTail, incoming]).subarray(
|
|
295
|
+
Math.max(0, fingerprintTail.length + incoming.length - TITLE_FINGERPRINT_SIZE),
|
|
296
|
+
)
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
const skippedThroughNewline = incoming.subarray(0, newline + 1)
|
|
300
|
+
fingerprintTail = Buffer.concat([fingerprintTail, skippedThroughNewline]).subarray(
|
|
301
|
+
Math.max(
|
|
302
|
+
0,
|
|
303
|
+
fingerprintTail.length + skippedThroughNewline.length - TITLE_FINGERPRINT_SIZE,
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
incoming = incoming.subarray(newline + 1)
|
|
307
|
+
skippingHugeLine = false
|
|
308
|
+
scannedBytes = position - incoming.length
|
|
309
|
+
}
|
|
310
|
+
const region = carry.length
|
|
311
|
+
? Buffer.concat([carry, incoming])
|
|
312
|
+
: incoming
|
|
313
|
+
const lastNewline = region.lastIndexOf(0x0a)
|
|
314
|
+
if (lastNewline < 0) {
|
|
315
|
+
if (region.length > LAST_TURN_MAX_LINE) {
|
|
316
|
+
carry = Buffer.alloc(0)
|
|
317
|
+
skippingHugeLine = true
|
|
318
|
+
scannedBytes = position
|
|
319
|
+
fingerprintTail = region.subarray(Math.max(0, region.length - TITLE_FINGERPRINT_SIZE))
|
|
320
|
+
} else {
|
|
321
|
+
carry = region
|
|
322
|
+
titleMaxCarryBytesForTest = Math.max(titleMaxCarryBytesForTest, carry.length)
|
|
323
|
+
}
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
const complete = region.subarray(0, lastNewline)
|
|
327
|
+
for (const lineBuf of splitLinesBuf(complete)) {
|
|
328
|
+
if (lineBuf.length > LAST_TURN_MAX_LINE) continue
|
|
329
|
+
const titleState = { customTitle, aiTitle }
|
|
330
|
+
takeTitleFromLine(lineBuf.toString("utf-8"), titleState)
|
|
331
|
+
customTitle = titleState.customTitle
|
|
332
|
+
aiTitle = titleState.aiTitle
|
|
333
|
+
}
|
|
334
|
+
carry = region.subarray(lastNewline + 1)
|
|
335
|
+
titleMaxCarryBytesForTest = Math.max(titleMaxCarryBytesForTest, carry.length)
|
|
336
|
+
scannedBytes = position - carry.length
|
|
337
|
+
const consumed = region.subarray(0, lastNewline + 1)
|
|
338
|
+
fingerprintTail = Buffer.concat([fingerprintTail, consumed]).subarray(
|
|
339
|
+
Math.max(0, fingerprintTail.length + consumed.length - TITLE_FINGERPRINT_SIZE),
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const current = fileMap.get(filePath) || {}
|
|
344
|
+
fileMap.set(filePath, {
|
|
345
|
+
...current,
|
|
346
|
+
customTitle,
|
|
347
|
+
aiTitle,
|
|
348
|
+
scannedBytes,
|
|
349
|
+
titleFingerprint: Buffer.from(fingerprintTail),
|
|
350
|
+
titleMtimeMs: mtimeMs,
|
|
351
|
+
titleSkippingHugeLine: skippingHugeLine,
|
|
352
|
+
titleScanPromise: scanPromise,
|
|
353
|
+
})
|
|
354
|
+
} catch {
|
|
355
|
+
// 個別ファイルの read エラーでは一覧自体を失敗させず、前回値を維持する。
|
|
356
|
+
} finally {
|
|
357
|
+
await handle?.close()
|
|
358
|
+
}
|
|
359
|
+
})()
|
|
360
|
+
entry.titleScanPromise = scanPromise
|
|
361
|
+
try {
|
|
362
|
+
await scanPromise
|
|
363
|
+
} finally {
|
|
364
|
+
const current = fileMap.get(filePath)
|
|
365
|
+
if (current?.titleScanPromise === scanPromise) delete current.titleScanPromise
|
|
366
|
+
}
|
|
367
|
+
return titleFromEntry(fileMap.get(filePath) || entry)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** テスト用: 呼び出し側 snapshot を偽装して extractTitleCached を直接叩く (round2 F2 の逆順再現)。 */
|
|
371
|
+
export function extractTitleCachedForTest(filePath, size, mtimeMs) {
|
|
372
|
+
return extractTitleCached(getLastTurnDirMap(path.dirname(filePath)), filePath, size, mtimeMs)
|
|
373
|
+
}
|
|
374
|
+
|
|
176
375
|
/** テスト用: last_turn_at_ms キャッシュを全消去する。 */
|
|
177
376
|
export function clearLastTurnCacheForTest() {
|
|
178
377
|
lastTurnDirCache.clear()
|
|
@@ -968,7 +1167,8 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
|
968
1167
|
const sessions = []
|
|
969
1168
|
for (const s of top) {
|
|
970
1169
|
const preview = await extractPreview(s.file_path)
|
|
971
|
-
|
|
1170
|
+
const title = await extractTitleCached(fileMap, s.file_path, s.size_bytes, s.mtime)
|
|
1171
|
+
sessions.push({ ...s, preview, ...(title === undefined ? {} : { title }) })
|
|
972
1172
|
}
|
|
973
1173
|
return { sessions }
|
|
974
1174
|
}
|
|
@@ -422,6 +422,9 @@ function isSessionRunningEvent(msg) {
|
|
|
422
422
|
* 正しく終端として扱う (ガードが固着して _busy が永久 true になる = T06052 の再発を防ぐ安全弁)。 */
|
|
423
423
|
const STALE_IDLE_GUARD_MS = 5000
|
|
424
424
|
|
|
425
|
+
/** per-message の title control request を待って InputQueue を閉じる上限。 */
|
|
426
|
+
const SESSION_TITLE_CLOSE_WAIT_MS = 15_000
|
|
427
|
+
|
|
425
428
|
/**
|
|
426
429
|
* 1 stream に対応する Claude セッション (per-message query モデル)。
|
|
427
430
|
*/
|
|
@@ -498,6 +501,12 @@ export class ClaudeStreamSession {
|
|
|
498
501
|
|
|
499
502
|
/** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
|
|
500
503
|
this.sessionId = resumeSessionId || null
|
|
504
|
+
/** fresh resident query の初回プロンプト。query 起動時に一度だけ消費する。 */
|
|
505
|
+
this._pendingTitleSeed = null
|
|
506
|
+
/** 同じ session_id への title control request の二重送信を防ぐ。 */
|
|
507
|
+
this._titleRequestedFor = null
|
|
508
|
+
/** テストで timeout 経路を短縮可能にするが、実運用値は常に 15 秒。 */
|
|
509
|
+
this._sessionTitleCloseWaitMs = SESSION_TITLE_CLOSE_WAIT_MS
|
|
501
510
|
|
|
502
511
|
/** browser 切断中フラグ。true でも query は中断せず継続する (絶対に落とさない)。 */
|
|
503
512
|
this._detached = false
|
|
@@ -1356,6 +1365,7 @@ export class ClaudeStreamSession {
|
|
|
1356
1365
|
// Phase 2 (major#1): resident fresh start は started を出さないため、注入なら source 付きで
|
|
1357
1366
|
// 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
|
|
1358
1367
|
this._maybeEmitInjectStarted(prompt)
|
|
1368
|
+
this._pendingTitleSeed = prompt
|
|
1359
1369
|
this._startResidentTurnWithInput(toSDKUserMessage(prompt), ultracode)
|
|
1360
1370
|
return { accepted: true }
|
|
1361
1371
|
}
|
|
@@ -1445,6 +1455,20 @@ export class ClaudeStreamSession {
|
|
|
1445
1455
|
}
|
|
1446
1456
|
// 直前ターンまでの session_id があれば resume チェーン
|
|
1447
1457
|
if (this.sessionId) options.resume = this.sessionId
|
|
1458
|
+
const isFreshStart = !options.resume
|
|
1459
|
+
// title 待ちの後始末 (timer / abort listener / InputQueue close) は try の外に持ち、
|
|
1460
|
+
// 正常完走・init 未到達の異常終端・abort のどの経路でも finally で 1 回だけ冪等に行う
|
|
1461
|
+
// (cross-review F4: init 前に generator が return/reject すると timer と listener が残っていた)。
|
|
1462
|
+
const abortSignal = this._abortController.signal
|
|
1463
|
+
let titleWait = null
|
|
1464
|
+
const finishTitleWait = () => {
|
|
1465
|
+
if (!titleWait) return
|
|
1466
|
+
const { inputQueue, timer } = titleWait
|
|
1467
|
+
titleWait = null
|
|
1468
|
+
if (timer) clearTimeout(timer)
|
|
1469
|
+
abortSignal.removeEventListener("abort", finishTitleWait)
|
|
1470
|
+
inputQueue.close()
|
|
1471
|
+
}
|
|
1448
1472
|
|
|
1449
1473
|
try {
|
|
1450
1474
|
// 改修4 (A): canUseTool は streaming input (AsyncIterable prompt) でのみ確実に機能する。
|
|
@@ -1452,9 +1476,20 @@ export class ClaudeStreamSession {
|
|
|
1452
1476
|
// resume チェーン挙動は維持しつつ、文字列 prompt + canUseTool の SDK 制約リスクを回避する。
|
|
1453
1477
|
const inputQueue = new InputQueue()
|
|
1454
1478
|
inputQueue.push(toSDKUserMessage(prompt))
|
|
1455
|
-
|
|
1456
|
-
const
|
|
1457
|
-
|
|
1479
|
+
const q = this.sdk.query({ prompt: inputQueue, options })
|
|
1480
|
+
const shouldWaitForTitle =
|
|
1481
|
+
isFreshStart && this._canGenerateSessionTitle(q, prompt)
|
|
1482
|
+
if (shouldWaitForTitle) {
|
|
1483
|
+
// 入力を即 close すると title control request が "Query closed before response
|
|
1484
|
+
// received" で失敗する (実測) ため、title settle か上限時間まで close を遅らせる。
|
|
1485
|
+
const timer = setTimeout(finishTitleWait, this._sessionTitleCloseWaitMs)
|
|
1486
|
+
timer.unref?.()
|
|
1487
|
+
titleWait = { inputQueue, timer }
|
|
1488
|
+
abortSignal.addEventListener("abort", finishTitleWait, { once: true })
|
|
1489
|
+
} else {
|
|
1490
|
+
inputQueue.close()
|
|
1491
|
+
}
|
|
1492
|
+
for await (const msg of q) {
|
|
1458
1493
|
if (
|
|
1459
1494
|
msg?.type === "system" &&
|
|
1460
1495
|
msg?.subtype === "init" &&
|
|
@@ -1463,6 +1498,9 @@ export class ClaudeStreamSession {
|
|
|
1463
1498
|
this.sessionId = msg.session_id
|
|
1464
1499
|
// session_id が確定/変化したら watch をその jsonl に張り替える
|
|
1465
1500
|
this._ensureWatch()
|
|
1501
|
+
if (shouldWaitForTitle) {
|
|
1502
|
+
this._requestSessionTitle(q, prompt, msg.session_id).finally(finishTitleWait)
|
|
1503
|
+
}
|
|
1466
1504
|
}
|
|
1467
1505
|
// result イベントでも session_id が来ることがある (念のため拾う)
|
|
1468
1506
|
if (msg?.type === "result") {
|
|
@@ -1490,6 +1528,8 @@ export class ClaudeStreamSession {
|
|
|
1490
1528
|
}
|
|
1491
1529
|
}
|
|
1492
1530
|
} finally {
|
|
1531
|
+
// init 未到達で generator が終端した場合も title 待ちの timer / listener を残さない。
|
|
1532
|
+
finishTitleWait()
|
|
1493
1533
|
this._abortController = null
|
|
1494
1534
|
// 未解決 permission は閉じ、browser の孤児カードも撤去する
|
|
1495
1535
|
this._denyAllPendingPermissions("turn ended")
|
|
@@ -1575,6 +1615,40 @@ export class ClaudeStreamSession {
|
|
|
1575
1615
|
}
|
|
1576
1616
|
}
|
|
1577
1617
|
|
|
1618
|
+
/** title 生成の静的条件。session_id 単位のガードは init 受信後に別途判定する。 */
|
|
1619
|
+
_canGenerateSessionTitle(q, seedText) {
|
|
1620
|
+
const trimmed = seedText.trim()
|
|
1621
|
+
return (
|
|
1622
|
+
process.env.HUB_AGENT_SESSION_TITLE !== "0" &&
|
|
1623
|
+
trimmed.length >= 10 &&
|
|
1624
|
+
!trimmed.startsWith("/") &&
|
|
1625
|
+
typeof q?.generateSessionTitle === "function"
|
|
1626
|
+
)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** title control request。失敗を通常ターンへ伝播させない。 */
|
|
1630
|
+
async _requestSessionTitle(q, seedText, sessionId) {
|
|
1631
|
+
if (
|
|
1632
|
+
this._titleRequestedFor === sessionId ||
|
|
1633
|
+
!this._canGenerateSessionTitle(q, seedText)
|
|
1634
|
+
) {
|
|
1635
|
+
return
|
|
1636
|
+
}
|
|
1637
|
+
this._titleRequestedFor = sessionId
|
|
1638
|
+
try {
|
|
1639
|
+
const result = await q.generateSessionTitle(seedText, { persist: true })
|
|
1640
|
+
this.logger?.info(
|
|
1641
|
+
{ title: result?.title, stream_id: this.stream_id },
|
|
1642
|
+
"session title generated",
|
|
1643
|
+
)
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
this.logger?.warn(
|
|
1646
|
+
{ err: err.message, stream_id: this.stream_id },
|
|
1647
|
+
"session title generation failed",
|
|
1648
|
+
)
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1578
1652
|
/** 改修3: pending キューの先頭を取り出して次の per-message ターンを発火する。
|
|
1579
1653
|
* 各ターンの finally から呼ばれ、キューが空になるまで resume チェーンが続く。 */
|
|
1580
1654
|
_drainPending() {
|
|
@@ -2042,6 +2116,8 @@ export class ClaudeStreamSession {
|
|
|
2042
2116
|
// 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
|
|
2043
2117
|
// query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
|
|
2044
2118
|
if (this.sessionId) options.resume = this.sessionId
|
|
2119
|
+
const titleSeed = options.resume ? null : this._pendingTitleSeed
|
|
2120
|
+
this._pendingTitleSeed = null
|
|
2045
2121
|
// 空 InputQueue 窓封鎖 (T06118): fresh start の初回ターンが ultracode を要求していれば、
|
|
2046
2122
|
// per-message と同じく起動時 options.settings に焼き込む。こうすることで「query を空 queue
|
|
2047
2123
|
// で先に起動 → applyFlagSettings を await → push」という空窓経路を踏まずに済む。初回だけ
|
|
@@ -2068,6 +2144,9 @@ export class ClaudeStreamSession {
|
|
|
2068
2144
|
typeof msg.session_id === "string"
|
|
2069
2145
|
) {
|
|
2070
2146
|
this.sessionId = msg.session_id
|
|
2147
|
+
if (titleSeed != null) {
|
|
2148
|
+
void this._requestSessionTitle(q, titleSeed, msg.session_id)
|
|
2149
|
+
}
|
|
2071
2150
|
}
|
|
2072
2151
|
if (msg?.type === "result") {
|
|
2073
2152
|
if (typeof msg.session_id === "string") this.sessionId = msg.session_id
|