@cocorograph/hub-agent 0.7.105 → 0.8.1

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.7.105",
3
+ "version": "0.8.1",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -647,116 +647,3 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
647
647
  * @param {{viewingSessionId?: string|null, newestSessionId?: string|null, lastNotifiedNewId?: string|null, lastNotifiedAt?: number|null, now?: number|null, reNotifyMs?: number}} args
648
648
  * @returns {{rotated: boolean, newSessionId?: string}}
649
649
  */
650
- /**
651
- * 回転候補 jsonl が「bound TUI セッションの本物の /clear 先」か「別アクティビティの jsonl」かを
652
- * head 4KB だけ読んで判定する純粋ヘルパ (2a 根治)。
653
- *
654
- * 背景: 同一 cwd-encoded dir には bound TUI 以外の jsonl が混入し得る:
655
- * - Task tool subagent (`parent_uuid` / `parentUuid` 付き)
656
- * - SDK headless `claude -p` (末尾近傍に `type=result`)
657
- * - 並走 cockpit / 別ターミナル起動の別 TUI (cwd は一致するため弱い signal)
658
- * - 既存 jsonl が touch されて mtime 最新化 (cwd が viewCwd と不一致なケースは cross-cwd 汚染)
659
- * これらに対して decideSessionRotation は newest≠viewing で無条件発火するため、ローテに乗ると
660
- * viewer が別 jsonl へ rotate-bind されて /clear 後の三点リーダー固着・誤生成中表示を起こす。
661
- *
662
- * 検出基準 (どれか 1 つでも該当したら reject = 抑止):
663
- * - `parent_uuid` / `parentUuid` / `parent_session_id` / `parentSessionId` (subagent marker)
664
- * - `type === 'result'` (SDK headless の完了 marker。先頭 4KB 内に現れたら頭 0KB の極短セッション)
665
- * - `cwd` フィールドが `viewCwd` と完全不一致 (cross-cwd 汚染)
666
- *
667
- * 失敗安全: head 読み失敗 / parse 失敗時は accept (旧挙動 = 即時 rotate へ degrade)。
668
- * 「/clear が壊れる」リスクは取らないため、不確実時は安全側 (accept) に倒す。
669
- *
670
- * @param {{candidatePath?: string|null, viewCwd?: string|null, readImpl?: (p:string,n:number)=>Promise<string|null>, logger?: import('pino').Logger}} args
671
- * @returns {Promise<{accept: boolean, reason: string}>}
672
- */
673
- export async function validateRotationCandidate({
674
- candidatePath,
675
- viewCwd,
676
- readImpl,
677
- logger,
678
- } = {}) {
679
- if (!candidatePath || !viewCwd) {
680
- return { accept: true, reason: "missing_args" }
681
- }
682
- let text
683
- try {
684
- if (typeof readImpl === "function") {
685
- text = await readImpl(candidatePath, 4096)
686
- } else {
687
- const handle = await open(candidatePath, "r")
688
- try {
689
- const buf = Buffer.allocUnsafe(4096)
690
- const { bytesRead } = await handle.read(buf, 0, buf.length, 0)
691
- text = buf.toString("utf-8", 0, bytesRead)
692
- } finally {
693
- await handle.close().catch(() => {})
694
- }
695
- }
696
- } catch (err) {
697
- logger?.warn(
698
- { err: err?.message, candidatePath },
699
- "rotation candidate head read failed → accept (degrade to legacy behavior)",
700
- )
701
- return { accept: true, reason: `read_failed:${err?.code || "unknown"}` }
702
- }
703
- if (!text) return { accept: true, reason: "empty_text" }
704
- for (const line of text.split("\n")) {
705
- if (!line) continue
706
- let obj
707
- try {
708
- obj = JSON.parse(line)
709
- } catch {
710
- continue
711
- }
712
- if (!obj || typeof obj !== "object") continue
713
- if (
714
- obj.parent_uuid ||
715
- obj.parentUuid ||
716
- obj.parent_session_id ||
717
- obj.parentSessionId
718
- ) {
719
- return { accept: false, reason: "subagent_marker" }
720
- }
721
- if (obj.type === "result") {
722
- return { accept: false, reason: "headless_result_marker" }
723
- }
724
- if (typeof obj.cwd === "string" && obj.cwd && obj.cwd !== viewCwd) {
725
- return { accept: false, reason: "cwd_mismatch" }
726
- }
727
- }
728
- return { accept: true, reason: "passed" }
729
- }
730
-
731
- export function decideSessionRotation({
732
- viewingSessionId,
733
- newestSessionId,
734
- lastNotifiedNewId,
735
- lastNotifiedAt,
736
- lastNotifiedCount = 0,
737
- now,
738
- reNotifyMs = 4000,
739
- maxReNotify = 5,
740
- } = {}) {
741
- if (!newestSessionId || !viewingSessionId) return { rotated: false }
742
- if (newestSessionId === viewingSessionId) return { rotated: false }
743
- if (newestSessionId === lastNotifiedNewId) {
744
- // 同一 new。時刻情報が揃っていて再通知間隔を超えていれば、固着救済のため再通知する。
745
- // ただし安全弁: 同一 (viewing→newest) ペアを maxReNotify 回再通知してもフロントが
746
- // 採用しない場合は打ち切る (capped)。フロントが viewing を更新しない=外来 id 汚染や
747
- // 別 cwd の rotated 誤採用の兆候で、これを無制限に再通知すると rotated 無限ピンポンに
748
- // なる (perf監査: cockpit↔cocomiru で rotated 244件/h を実測)。
749
- if (lastNotifiedCount >= maxReNotify) {
750
- return { rotated: false, capped: true }
751
- }
752
- if (
753
- typeof now === "number" &&
754
- typeof lastNotifiedAt === "number" &&
755
- now - lastNotifiedAt >= reNotifyMs
756
- ) {
757
- return { rotated: true, newSessionId: newestSessionId }
758
- }
759
- return { rotated: false }
760
- }
761
- return { rotated: true, newSessionId: newestSessionId }
762
- }
@@ -85,6 +85,42 @@ const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
85
85
  const CHAT_RESIDENT_RESUME_ENABLED =
86
86
  process.env.HUB_AGENT_CHAT_RESIDENT_RESUME !== "0"
87
87
 
88
+ /** 受理済み input_id リングの上限 (T06603, 2026-07-25)。queue snapshot に載せて
89
+ * browser の「実行完了 vs 未配達」判定に使う。1 セッションの送信待ちは実運用で
90
+ * 数件〜十数件なので 256 あれば「表示中の楽観チップが評価対象から溢れる」ことは無い。
91
+ * 溢れた場合も誤って消すのではなく「未配達 (要再送)」側に倒れるため安全側。 */
92
+ const ACCEPTED_INPUT_ID_RING_MAX = 256
93
+
94
+ /** キュー snapshot の世代 (T06603, 2026-07-25)。
95
+ *
96
+ * 版番号 (seq) は **セッションオブジェクト単位**の連番なので、同一プロセス内でも
97
+ * session が作り直される (resume / 再 attach) と 1 に戻る。一方 backend の写しキャッシュと
98
+ * browser は単調な版を覚えているため、seq だけで比較すると新しい snapshot が永久に
99
+ * 「古い」と判定されて **表示が凍り付く**。そこで seq の上位に世代を置く。
100
+ *
101
+ * ⚠️ **この値が保証するのは「同一プロセス内」の順序だけ** (cross-review round2)。値は
102
+ * 壁時計 ms で、プロセス内では必ず前回より大きい値を返す (同一 ms の衝突回避 + 時計が
103
+ * 巻き戻っても +1 で単調) が、**プロセス再起動をまたぐ順序は保証しない** (NTP のステップ
104
+ * 補正・手動時刻変更・別ホスト間の時計差で逆転しうる)。
105
+ *
106
+ * そしてその保証は **不要** である (cross-review 3 巡目で設計確定)。browser は
107
+ * ① 適用する queue_state を own stream の authoritative コピーだけに限る
108
+ * ② attach (claude.ready) のたびに版番号の基準点を外す
109
+ * という 2 点により、版番号空間を **1 回の attach の中に閉じている**。agent が再起動すれば
110
+ * browser は必ず再 attach するので (agent.status online → reattachSameSession)、
111
+ * プロセスを跨いで版を比較する場面が存在しない。
112
+ *
113
+ * ゆえにここで壁時計以上の保証を作り込もうとしないこと。プロセス間の全順序を求めると
114
+ * 永続採番 (DB sequence) と session 所有権リースが必要になり、表示のために分散合意に近い
115
+ * 機構を抱えることになる (実際に版番号方式では「時計後退で永久 reject」「非権威 producer に
116
+ * よる cache 汚染」の穴が連続して見つかり、設計ごと撤回した)。 */
117
+ let _lastQueueGeneration = 0
118
+ function nextQueueGeneration() {
119
+ const now = Date.now()
120
+ _lastQueueGeneration = now > _lastQueueGeneration ? now : _lastQueueGeneration + 1
121
+ return _lastQueueGeneration
122
+ }
123
+
88
124
  /** MCP control 専用 query のアイドル破棄時間 (2026-06-03)。MCP サーバーは Claude Code
89
125
  * 設定 (アカウント単位) で全セッション共有のため、チャットセッションとは独立した
90
126
  * 「control 専用 query」を 1 本だけ遅延起動して状態取得・再認証・再接続を行う。
@@ -434,6 +470,24 @@ export class ClaudeStreamSession {
434
470
  /** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
435
471
  * 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
436
472
  this._lastEmittedQueueSig = "0:"
473
+ /** キュー snapshot の版番号 (T06603, 2026-07-25)。emit するたびに単調増加する。
474
+ * browser は「受け取った snapshot が手元より新しいときだけ適用する」ことで、
475
+ * ①遅延した relay / backend キャッシュの古い snapshot による巻き戻し
476
+ * ②stream group と session group の二重配送
477
+ * を無害化できる。**欠落の検知には使わない** (snapshot は全状態を含むので、
478
+ * 落ちた版があっても次に届いた版を適用すれば正しくなる)。落ちたまま次が来ない
479
+ * ケースの救済は browser 側の pull (claude.queue.request) が担う。 */
480
+ this._queueStateSeq = 0
481
+ /** この session オブジェクトの世代 (T06603)。上記 nextQueueGeneration の説明を参照。
482
+ * **同一プロセス内の順序のみ**を保証する (プロセス間は backend の接続フェンスが担う)。 */
483
+ this._queueGeneration = nextQueueGeneration()
484
+ /** 配達確認の再表明用 (T06603): この session が受理 (キュー投入 or 直接配送) した
485
+ * input_id の直近リング。`claude.input.ack` は一度きりのユニキャストで、WS 切断が
486
+ * 30s を超えると ws-client の ctrl バッファから破棄され二度と再送されない。その結果
487
+ * browser は「実際には届いて実行されたのに未配達扱い」で送信待ちチップを固着させる。
488
+ * snapshot にこのリングを載せることで、ack を取りこぼした browser も
489
+ * 「キューに無い + 受理済み = 実行完了」と判定でき、pull だけで自己回復できる。 */
490
+ this._acceptedInputIds = []
437
491
 
438
492
  /** 連携カード種別タグ (Phase 2, 2026-07-23): ask-sibling/orchestrate 等の **別セッションからの
439
493
  * 注入** を、受け手画面で「青い自分バブル」ではなく専用の連携依頼カードで表示させるため、
@@ -950,6 +1004,19 @@ export class ClaudeStreamSession {
950
1004
  * `accepted:false` のときは呼び出し元 (ClaudeStreamBridge.input) が nack を組み立てる。
951
1005
  */
952
1006
  async sendMessage(message, opts = {}) {
1007
+ const result = await this._sendMessageInner(message, opts)
1008
+ // 配達確認の再表明 (T06603): 受理 (= ack を返す) の記録を **1 箇所に集約**する。
1009
+ // 投入経路は busy キュー / resident 直行 / fresh start / per-message と 4 通りあり、
1010
+ // 各経路に散らすと新経路追加のたびに記録漏れが起き、browser が「未配達」と誤判定して
1011
+ // チップを固着させる。ack を返す条件 (accepted) と同じ場所で 1 回だけ記録する。
1012
+ const inputId =
1013
+ typeof opts?.input_id === "string" && opts.input_id ? opts.input_id : null
1014
+ if (inputId && result?.accepted) this._recordAcceptedInput(inputId)
1015
+ return result
1016
+ }
1017
+
1018
+ /** sendMessage 本体 (受理記録は呼び出し元の sendMessage が funnel で行う)。 */
1019
+ async _sendMessageInner(message, opts = {}) {
953
1020
  if (this._closed) return { accepted: false, reason: "queue_dead" }
954
1021
  const prompt = extractPromptText(message)
955
1022
  if (!prompt) return { accepted: false, reason: "empty_message" }
@@ -1227,7 +1294,7 @@ export class ClaudeStreamSession {
1227
1294
  return
1228
1295
  }
1229
1296
  const next = this._pendingMessages.shift()
1230
- this._emitQueueState([next.text])
1297
+ this._emitQueueState([next.text], { startedIds: [next.id] })
1231
1298
  this._runPerMessage(next.text, { ultracode: next.ultracode === true }).catch(
1232
1299
  (err) => {
1233
1300
  this.logger?.error(
@@ -1245,10 +1312,37 @@ export class ClaudeStreamSession {
1245
1312
  // (楽観チップの id と一致させ、確認前キャンセルを可能にする)。無ければ従来の qN。
1246
1313
  const id =
1247
1314
  typeof inputId === "string" && inputId ? inputId : `q${++this._queueSeq}`
1315
+ // 二重投入ガード (T06603, 2026-07-25): 同一 input_id が既にキューに居るなら積まない。
1316
+ // main.mjs の in-flight dedup (claudeInflightByInputId) で通常は到達しないが、
1317
+ // 「in-flight レジストリから外れた直後 (settle 済み・seen 登録前) の再送」等の窓では
1318
+ // ここが最後の砦になる。id 重複を許すと ①同じ本文が 2 回実行される
1319
+ // ②drain 後も同 id の項目がキューに残り送信待ちチップが消えない、の二重障害になる。
1320
+ if (this._pendingMessages.some((m) => m.id === id)) {
1321
+ this.logger?.info(
1322
+ { stream_id: this.stream_id, id },
1323
+ "duplicate input_id already queued, skipped",
1324
+ )
1325
+ return id
1326
+ }
1248
1327
  this._pendingMessages.push({ id, text: prompt, ultracode: ultracode === true })
1249
1328
  return id
1250
1329
  }
1251
1330
 
1331
+ /** 配達確認の再表明 (T06603): 受理済み input_id をリングへ記録する。
1332
+ * snapshot の `accepted` として browser へ載せ、ack 喪失時の自己回復に使う。 */
1333
+ _recordAcceptedInput(inputId) {
1334
+ if (typeof inputId !== "string" || !inputId) return
1335
+ const at = this._acceptedInputIds.indexOf(inputId)
1336
+ if (at !== -1) this._acceptedInputIds.splice(at, 1)
1337
+ this._acceptedInputIds.push(inputId)
1338
+ if (this._acceptedInputIds.length > ACCEPTED_INPUT_ID_RING_MAX) {
1339
+ this._acceptedInputIds.splice(
1340
+ 0,
1341
+ this._acceptedInputIds.length - ACCEPTED_INPUT_ID_RING_MAX,
1342
+ )
1343
+ }
1344
+ }
1345
+
1252
1346
  /** キャンセル機能 (0.6.26): browser から id 指定で送信待ちメッセージを 1 件取り消す。
1253
1347
  * 実行開始済み (既に drain された) メッセージは pending に残っていないので no-op になる。
1254
1348
  * 削除に成功したら queue_state を再 emit して送信待ちチップを更新する。 */
@@ -1269,9 +1363,30 @@ export class ClaudeStreamSession {
1269
1363
  { stream_id: this.stream_id, id, queued: this._pendingMessages.length },
1270
1364
  "queued message canceled",
1271
1365
  )
1272
- this._emitQueueState()
1273
1366
  }
1274
- return removed
1367
+ // T06603 cross-review 5 巡目: **「消えた」だけでは取消成功と実行開始済みを区別できない**。
1368
+ // drain の started フレームが落ちていると、browser は「キューに無い + 受理済み」で
1369
+ // 実行完了と判定してチップを消すが、ユーザーから見ると「取り消せた」ようにしか見えず、
1370
+ // しかも started 欠落で発言バブルも出ていない。ここで結末を明示する。
1371
+ // - pending から除去できた → cancelled
1372
+ // - pending に無い + 受理済み → already_started (もう走っている)
1373
+ // - pending に無い + 未受理 → cancelled (先着キャンセルガードを張ったので、
1374
+ // 後から届く同 id の input は破棄される)
1375
+ const outcome = removed
1376
+ ? "cancelled"
1377
+ : this._acceptedInputIds.includes(id)
1378
+ ? "already_started"
1379
+ : "cancelled"
1380
+ // ⚠️ T06603 cross-review 6 巡目: **snapshot をここで emit しない**。
1381
+ // 従来はここで force 再表明していたが、そのフレームは outcome 応答より先に browser へ
1382
+ // 届く (backend は agent の到着順に relay する)。先に届いた空 snapshot が「キューに無い
1383
+ // + 受理済み = 実行完了」の判定でチップを消してしまい、後続の outcome は対象不在で
1384
+ // no-op になる → already_started のバブル補償が発火しない。
1385
+ // 呼び出し元が outcome を送ってから flush() を呼ぶことで、配送順を確定させる。
1386
+ return {
1387
+ outcome,
1388
+ flush: () => this._emitQueueState([], { force: true }),
1389
+ }
1275
1390
  }
1276
1391
 
1277
1392
  /** 改修3: pending キューの現状を browser へ通知する (送信待ちチップ表示用)。
@@ -1285,6 +1400,12 @@ export class ClaudeStreamSession {
1285
1400
  * 署名が前回と同一でも browser がライブ配信を取りこぼしている可能性があるため。 */
1286
1401
  _emitQueueState(started = [], opts = undefined) {
1287
1402
  const force = opts?.force === true
1403
+ // 実行開始した項目の input_id (started と parallel。T06603 cross-review major#2)。
1404
+ // 本文テキストだけだと、同一本文を連投したときに 2 通目のユーザー発言バブルが
1405
+ // 「既に同じ本文のバブルがある」と誤判定されて永久に出ない。id で突合させる。
1406
+ const startedIds = Array.isArray(opts?.startedIds)
1407
+ ? opts.startedIds
1408
+ : started.map(() => null)
1288
1409
  const count = this._pendingMessages.length
1289
1410
  // 署名 = 件数 + id 列。件数が同じでもキャンセルで中身が変われば通知する。
1290
1411
  const sig = `${count}:${this._pendingMessages.map((m) => m.id).join(",")}`
@@ -1311,19 +1432,47 @@ export class ClaudeStreamSession {
1311
1432
  // 昇格させるため、ここで切り詰めると本文が欠ける。チップの省略表示は CSS 側で行う。
1312
1433
  // items は id 付きでキャンセルボタンの対象特定に使う (0.6.26)。
1313
1434
  this.onEvent?.({
1314
- type: "queue_state",
1315
- pending: count,
1316
- messages: this._pendingMessages.map((m) => m.text),
1317
- items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
1435
+ ...this._buildQueueSnapshot(),
1318
1436
  started,
1319
1437
  // started と parallel。連携注入なら 'peer'|'orchestrator'、通常は null。
1320
1438
  startedSources,
1439
+ // started と parallel。キュー由来なら input_id、注入等で id が無ければ null。
1440
+ startedIds,
1321
1441
  })
1322
1442
  } catch {
1323
1443
  /* ignore */
1324
1444
  }
1325
1445
  }
1326
1446
 
1447
+ /** キュー snapshot (T06603, 2026-07-25): 現在の権威キュー状態を 1 個のオブジェクトに
1448
+ * まとめる。ライブ配信 (_emitQueueState) と pull 応答 (claude.queue.request) の
1449
+ * **両方がこの 1 関数を通る**ため、経路によって形が食い違うことがない。
1450
+ * 版番号 (seq) はここで採番する = 「browser へ出す状態が 1 つ進んだ」の唯一の定義。 */
1451
+ _buildQueueSnapshot() {
1452
+ this._queueStateSeq += 1
1453
+ return {
1454
+ type: "queue_state",
1455
+ // 世代 (T06603 cross-review blocker): 再起動 / resume で seq が 1 に戻っても、
1456
+ // 受け手が「古い」と誤判定しないよう上位キーとして必ず載せる。
1457
+ generation: this._queueGeneration,
1458
+ seq: this._queueStateSeq,
1459
+ pending: this._pendingMessages.length,
1460
+ messages: this._pendingMessages.map((m) => m.text),
1461
+ items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
1462
+ // 受理済み input_id (直近リング)。browser は「キューに無い + ここに有る = 実行完了」
1463
+ // と判定して楽観チップを消し、「キューに無い + ここにも無い = 未配達」なら
1464
+ // 消さずに再送導線を出す (無言削除の回帰を作らないための 2 軸判定)。
1465
+ accepted: [...this._acceptedInputIds],
1466
+ }
1467
+ }
1468
+
1469
+ /** pull 型再同期 (T06603): 現在のキュー snapshot を返す (emit はしない)。
1470
+ * browser の claude.queue.request に対して claude.queue.response で返す。
1471
+ * 純粋な読み取りなので Read-plane (並行レーン) から呼んでよい。 */
1472
+ queueSnapshot() {
1473
+ return this._buildQueueSnapshot()
1474
+ }
1475
+
1327
1476
  /** 連携カード種別 (Phase 2, cross-review major#1): **idle 直接送信経路**用。busy→queue→drain は
1328
1477
  * _emitQueueState([text]) を通るが、idle 直接実行 (resident fresh start / per-message 即時) は
1329
1478
  * started を出さないため、注入の source が startedSources に載らずカード化されない。ここで
@@ -1527,7 +1676,11 @@ export class ClaudeStreamSession {
1527
1676
  // codex-review minor: session が close 中 (outcome==="closed") は next を配送していない。
1528
1677
  // started 通知 (queue_state に [next.text] を載せる) を出すと未配送メッセージが「開始済み」に
1529
1678
  // 見えるため抑止する。closed は終端状態なので pending へ戻さず破棄でよい (session 撤去中)。
1530
- if (outcome !== "closed") this._emitQueueState([next.text])
1679
+ // T06603 (2026-07-25): ただし破棄したこと自体は通知する (started 無しの force 再表明)。
1680
+ // 従来は shift 済みなのに emit しないため、当該項目のチップが browser に残り続けていた。
1681
+ if (outcome !== "closed")
1682
+ this._emitQueueState([next.text], { startedIds: [next.id] })
1683
+ else this._emitQueueState([], { force: true })
1531
1684
  } catch (err) {
1532
1685
  this._busy = false
1533
1686
  this._pendingMessages.unshift(next)
@@ -1709,7 +1862,7 @@ export class ClaudeStreamSession {
1709
1862
  toSDKUserMessage(next.text),
1710
1863
  next.ultracode === true,
1711
1864
  )
1712
- this._emitQueueState([next.text])
1865
+ this._emitQueueState([next.text], { startedIds: [next.id] })
1713
1866
  }
1714
1867
  // マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
1715
1868
  // closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
@@ -1868,7 +2021,12 @@ export class ClaudeStreamSession {
1868
2021
  close() {
1869
2022
  this._closed = true
1870
2023
  // 改修3: 未処理の pending を破棄 (セッション終了後に drain しないため)。
2024
+ const hadPending = this._pendingMessages.length > 0
1871
2025
  this._pendingMessages = []
2026
+ // T06603 (2026-07-25): 破棄したことを必ず通知する。従来は無言で捨てていたため、
2027
+ // 「セッションが畳まれた後もチップだけが残る」表示が残留していた。破棄は状態変化なので
2028
+ // force で貫通させる (started=[] なのでバブル昇格は起きない)。
2029
+ if (hadPending) this._emitQueueState([], { force: true })
1872
2030
  // 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
1873
2031
  if (this._inputQueue) this._inputQueue.close()
1874
2032
  this.abortTurn()
@@ -2932,11 +3090,26 @@ export class ClaudeStreamBridge extends EventEmitter {
2932
3090
  const s = this.sessions.get(stream_id)
2933
3091
  if (!s) {
2934
3092
  this.logger?.warn({ stream_id, id }, "claude.queue.cancel but stream missing")
2935
- return false
3093
+ // T06603 cross-review 4/5 巡目: **結末を必ず browser へ返す**。browser はキャンセル
3094
+ // 要求時にチップを消さず「取消中」の印を付けて権威の確認を待つ設計なので、
3095
+ // 無応答だと印が外れる契機が無く「取消中」のまま固着する。
3096
+ return { outcome: "stream_missing", flush: () => {} }
2936
3097
  }
2937
3098
  return s.cancelQueued(id)
2938
3099
  }
2939
3100
 
3101
+ /** pull 型再同期 (T06603, 2026-07-25): browser の claude.queue.request に答えるため、
3102
+ * 現在の権威キュー snapshot を返す。stream が無ければ null (browser 側は
3103
+ * 「まだ attach していない」として扱い、ready 後に引き直す)。 */
3104
+ queueSnapshot({ stream_id }) {
3105
+ const s = this.sessions.get(stream_id)
3106
+ if (!s) {
3107
+ this.logger?.warn({ stream_id }, "claude.queue.request but stream missing")
3108
+ return null
3109
+ }
3110
+ return { session_id: s.sessionId || null, snapshot: s.queueSnapshot() }
3111
+ }
3112
+
2940
3113
  /**
2941
3114
  * セッション停止 (graceful)。実行中ターンは中断せず完走させ、完走後に
2942
3115
  * onReap で Map から撤去する。アイドルなら即時撤去。
@@ -181,6 +181,49 @@ export async function getActiveConfigDir() {
181
181
  return (await getActiveProfile()).configDir
182
182
  }
183
183
 
184
+ /**
185
+ * profile_id から Codex ホーム (configDir) を解決する (self-describing read 用)。
186
+ *
187
+ * 【なぜ env でなくリクエストの id から引くのか — Read-plane 案C ①】
188
+ * `process.env.CODEX_HOME` はプロファイル切替 (applyActiveCodexEnv) で書き換わる揮発値。
189
+ * read (一覧・履歴) を並行に走らせると、切替の瞬間に in-flight の read が別アカウントの
190
+ * rollout dir を読む競合が起きる。read リクエスト自身が profile_id を搬送し、読む対象を
191
+ * リクエスト時点で固定することで根絶する。profiles.mjs の同名関数と対称。
192
+ *
193
+ * @param {string|null|undefined} profileId 未指定/空/未知 → active へフォールバック
194
+ * @returns {Promise<{configDir:string, profileId:string, resolved:boolean}>}
195
+ */
196
+ export async function resolveConfigDir(profileId) {
197
+ const reg = await loadRegistry()
198
+ if (profileId) {
199
+ const hit = reg.profiles.find((p) => p.id === profileId)
200
+ if (hit) {
201
+ return { configDir: hit.configDir, profileId: hit.id, resolved: true }
202
+ }
203
+ }
204
+ const active =
205
+ reg.profiles.find((p) => p.id === reg.activeId) || makeDefaultCodexProfile()
206
+ return { configDir: active.configDir, profileId: active.id, resolved: false }
207
+ }
208
+
209
+ /**
210
+ * profile_id から rollout の保存ルート (`<configDir>/sessions`) を解決する。
211
+ *
212
+ * ⚠️ `HUB_CODEX_SESSIONS_DIR` (テスト/特殊配置用の明示 override) は usage.mjs の
213
+ * codexSessionsDir() と同じ優先順位で最優先する。override が効かなくなると
214
+ * テストが実ホームを読んでしまうため。
215
+ *
216
+ * @param {string|null|undefined} profileId
217
+ * @returns {Promise<{sessionsDir:string, profileId:string, resolved:boolean}>}
218
+ */
219
+ export async function resolveSessionsDir(profileId) {
220
+ const r = await resolveConfigDir(profileId)
221
+ const sessionsDir = process.env.HUB_CODEX_SESSIONS_DIR
222
+ ? process.env.HUB_CODEX_SESSIONS_DIR
223
+ : path.join(r.configDir, "sessions")
224
+ return { sessionsDir, profileId: r.profileId, resolved: r.resolved }
225
+ }
226
+
184
227
  /**
185
228
  * active プロファイルを切り替える。存在しない id はエラー。
186
229
  * env (CODEX_HOME) の反映と全 Codex セッション再起動は呼び出し側 (main.mjs) の責務。
@@ -44,7 +44,7 @@ import { EventEmitter } from "node:events"
44
44
 
45
45
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
46
46
  import { getOrCreateSiblingToken } from "./sibling-token.mjs"
47
- import { codexRolloutPresence } from "./usage.mjs"
47
+ import { codexRolloutPresence, listCodexThreadsFromDisk } from "./usage.mjs"
48
48
 
49
49
  /**
50
50
  * sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
@@ -306,6 +306,7 @@ export class CodexStreamBridge extends EventEmitter {
306
306
  deadTerminalTtlMs,
307
307
  turnStartTimeoutMs,
308
308
  rolloutPresence,
309
+ listThreadsFromDisk,
309
310
  historyMaxItems,
310
311
  historyMaxBytes,
311
312
  } = {}) {
@@ -326,6 +327,9 @@ export class CodexStreamBridge extends EventEmitter {
326
327
  this._turnStartTimeoutMs = turnStartTimeoutMs ?? TURN_START_TIMEOUT_MS
327
328
  // #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
328
329
  this._rolloutPresence = rolloutPresence || codexRolloutPresence
330
+ // Phase 3 (Read-plane スライス2a): 一覧を rollout の disk 走査で作る (app-server 非 spawn)。
331
+ // テストから stub を注入できるよう injectable。
332
+ this._listThreadsFromDisk = listThreadsFromDisk || listCodexThreadsFromDisk
329
333
  // T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
330
334
  this._historyLimits = {
331
335
  maxItems: historyMaxItems ?? HISTORY_MAX_ITEMS,
@@ -757,58 +761,42 @@ export class CodexStreamBridge extends EventEmitter {
757
761
  * cwd 配下の Codex thread 一覧を返す (Cockpit Codex 両対応 セッションピッカー/最新
758
762
  * セッション自動選択用。claude 側 listSessions と対称)。
759
763
  *
760
- * `thread/list` は特定のスレッドに紐付かない app-server のグローバル RPC (公式スキーマ
761
- * `ThreadListParams`/`ThreadListResponse` で確認済み: cwd フィルタ・sortKey='recency_at'・
762
- * sortDirection='desc' が使える) なので、attach() のような常駐セッションは作らず、
763
- * 呼び出しごとに使い捨てクライアントで initialize → thread/list → stop する
764
- * (list のためだけに長寿命プロセスを維持しない = リーク防止)。
764
+ * Phase 3 (Read-plane スライス2a, 2026-07-24): **app-server を spawn せず rollout の disk 走査**で
765
+ * 一覧を作る (`listCodexThreadsFromDisk`)。旧実装は使い捨て app-server で `thread/list` を
766
+ * 叩いていたが、完了基準「Codex 一覧で app-server を起動しない」に反し、Read-plane で off-queue
767
+ * 化しても spawn コスト自体が残っていた。一覧に要る 4 フィールド (thread_id/cwd/preview/
768
+ * updated_at_ms) は rollout の header + mtime から安価に取れる。返り値 shape は従来と同一。
769
+ *
770
+ * single-flight (下記 in-flight 畳み込み) は spawn storm 防止用だったが、disk 走査化後は
771
+ * 「同一 (cwd,limit) の同時走査の冗長 I/O 抑止」として維持する (無害・低コスト)。
765
772
  *
766
773
  * @returns {Promise<Array<{thread_id: string, cwd: string, preview: string, updated_at_ms: number}>>}
767
774
  */
768
- async listThreads({ cwd, limit } = {}) {
769
- // Phase 3 (Read-plane): single-flight。同一 (cwd, limit) への同時リクエストを 1 本の
770
- // in-flight Promise に畳み、app-server の同時 spawn (storm) を防ぐ。異なる cwd は個別に走る。
771
- const key = `${cwd || ""}::${limit ?? ""}`
775
+ async listThreads({ cwd, limit, sessionsDir } = {}) {
776
+ // Phase 3 (Read-plane): single-flight。同一 (読み取り先, cwd, limit) への同時リクエストを
777
+ // 1 本の in-flight Promise に畳む。
778
+ //
779
+ // key の第 1 要素は **実際に読む sessionsDir** にする (Read-plane 案C ①)。
780
+ // 以前は `process.env.CODEX_HOME` を使っていたが、env は切替で書き換わる揮発値なので
781
+ // 「この in-flight がどのアカウントの結果か」を表せず、切替中に別プロファイルの結果を
782
+ // 取り違えうる。解決済みの読み取り先そのものを key にすれば同一性が構造的に保証される。
783
+ // sessionsDir 未指定 (旧経路) のときだけ env 由来の値へフォールバックする。
784
+ const readRoot = sessionsDir || process.env.CODEX_HOME || ""
785
+ const key = `${readRoot}::${cwd || ""}::${limit ?? ""}`
772
786
  const existing = this._listThreadsInFlight.get(key)
773
787
  if (existing) return existing
774
- const p = this._doListThreads({ cwd, limit }).finally(() => {
788
+ const p = this._doListThreads({ cwd, limit, sessionsDir }).finally(() => {
775
789
  this._listThreadsInFlight.delete(key)
776
790
  })
777
791
  this._listThreadsInFlight.set(key, p)
778
792
  return p
779
793
  }
780
794
 
781
- async _doListThreads({ cwd, limit } = {}) {
782
- const client = this._clientFactory({ cwd, codexCmd: this.codexCmd, logger: this.logger })
783
- client.start()
784
- try {
785
- await client.request("initialize", { clientInfo: CLIENT_INFO })
786
- const result = await client.request("thread/list", {
787
- cwd: cwd || undefined,
788
- limit: limit || undefined,
789
- // ⚠️ updated_at を使う (recency_at ではない)。recency_at は「読み込み(=スレッドを
790
- // 開くだけ)も含めた使用感」の並び順なので、古いスレッドを開いただけでサイドバー最上位
791
- // に浮上してしまう (症状⑨=実害)。サイドバーは「会話への書き込み(=ターン境界)」で
792
- // 並べたいので、最終書き込み時刻の updated_at を採用する。
793
- // (Claude 側 listSessions も jsonl 内の最終メッセージ timestamp = ターン境界に揃えた)
794
- sortKey: "updated_at",
795
- sortDirection: "desc",
796
- })
797
- const threads = Array.isArray(result?.data) ? result.data : []
798
- return threads.map((t) => ({
799
- thread_id: t.id,
800
- cwd: t.cwd || "",
801
- preview: typeof t.preview === "string" ? t.preview.slice(0, 80) : "",
802
- // updatedAt 優先 (無ければ recencyAt fallback) を ms へ変換。claude 側
803
- // ClaudeSessionSummary.mtime (ms epoch) と同じ単位に揃え、frontend が同じ並び替え
804
- // ロジックを使い回せるようにする。並び順の source of truth は上の sortKey なので、
805
- // ここは表示用の mtime も updatedAt を優先させて sortKey と一貫させる。
806
- updated_at_ms:
807
- (typeof t.updatedAt === "number" ? t.updatedAt : t.recencyAt || 0) * 1000,
808
- }))
809
- } finally {
810
- client.stop()
811
- }
795
+ async _doListThreads({ cwd, limit, sessionsDir } = {}) {
796
+ // Phase 3 (Read-plane スライス2a): app-server を spawn せず rollout の disk 走査で一覧を作る。
797
+ // updated_at_ms は rollout ファイルの mtime (= 最終書き込み時刻 = ターン境界に相当) で、
798
+ // claude 側 ClaudeSessionSummary.mtime と単位・意味を揃える。並びは mtime desc。
799
+ return this._listThreadsFromDisk({ cwd, limit, sessionsDir, logger: this.logger })
812
800
  }
813
801
 
814
802
  /**