@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.59",
3
+ "version": "0.8.60",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -494,28 +494,40 @@ ensure_node_tls_works() {
494
494
  exit 1
495
495
  }
496
496
 
497
- ensure_pkg() {
498
- local cmd="$1"
499
- local brew_pkg="$2"
500
- local apt_pkg="${3:-$brew_pkg}"
501
- if present "$cmd"; then color_ok "$cmd already installed"; return 0; fi
502
- color_step "$cmd をインストール"
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 "$apt_pkg"
515
+ retry 3 sudo pacman -S --noconfirm "$pacman_pkg" || return 1
513
516
  else
514
- color_err "対応するパッケージマネージャ (brew/apt/dnf/pacman) が見つかりません。$cmd を手動で install してください"
515
- exit 1
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
- if [[ "$(uname)" == "Darwin" ]]; then
594
- # macOS で python3 が動かないのは、ほぼ Xcode CLT 未導入(/usr/bin/python3 がスタブ)。
595
- # brew 経路が失敗したら、set -e で黙って落ちる前に実際の復旧手順を出す。
596
- if ! retry 3 brew install python; then
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
- # Claude Code CLI を同梱でセットアップ。Cockpit 側から claude を呼び出すので、
753
- # 同じ npm-global prefix に入れておくとパス解決が一貫する。
754
- # 既存があれば upgrade のみ(破壊しない)。
755
- ensure_claude_code() {
756
- if present claude; then
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=$(claude --version 2>/dev/null || echo "unknown")
759
- color_step "Claude Code (現在 $cur) を最新版にアップデート"
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 "${CLAUDE_CODE_PACKAGE}@latest" --force \
762
- || color_warn "Claude Code upgrade に失敗 (既存版で継続)"
783
+ retry 2 npm install -g "${package}@latest" --force \
784
+ || color_warn "$label upgrade に失敗 (既存版で継続)"
763
785
  else
764
- color_step "$CLAUDE_CODE_PACKAGE を install"
765
- retry 3 npm install -g "${CLAUDE_CODE_PACKAGE}@latest" --force
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 claude; then
769
- color_ok "claude $(claude --version 2>/dev/null || echo 'installed')"
797
+ if present "$cmd"; then
798
+ color_ok "$cmd $("$cmd" --version 2>/dev/null </dev/null || echo 'installed')"
770
799
  else
771
- color_warn "claude コマンドが PATH に見つかりません。新規シェルで再確認してください"
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
- if present codex; then
779
- local cur
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() {
@@ -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
- sessions.push({ ...s, preview })
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
- inputQueue.close()
1456
- const generator = this.sdk.query({ prompt: inputQueue, options })
1457
- for await (const msg of generator) {
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