@cocorograph/hub-agent 0.7.77 → 0.7.79

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.77",
3
+ "version": "0.7.79",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -177,6 +177,15 @@ function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = H
177
177
  return { items: kept, truncated, total }
178
178
  }
179
179
 
180
+ /** 履歴 item の概算 byte 数 (flattenThreadItems の budget 判定と同じ JSON 長ベース)。 */
181
+ function estimateItemBytes(item) {
182
+ try {
183
+ return JSON.stringify(item)?.length || 0
184
+ } catch {
185
+ return 0
186
+ }
187
+ }
188
+
180
189
  /**
181
190
  * resume 応答から Codex の「権威的なターン進行状態」を取り出す。
182
191
  *
@@ -332,6 +341,35 @@ export class CodexStreamBridge extends EventEmitter {
332
341
  }
333
342
  }
334
343
 
344
+ // T06541 案1: attach_id が無くても、同一 (cwd, resume_thread_id) のライブ session があれば
345
+ // in-memory reattach する。frontend は attach_id を送らないため上の attachId 経路は発火せず、
346
+ // これが実質のメイン reattach 経路になる。新プロセス thread/resume を起動せず所有権を付け替え、
347
+ // **蓄積済みの高忠実度履歴を返す**(frontend は remount で白紙化するため履歴が必要。reducer は
348
+ // uuid dedup で二重描画しない)。これにより症状⑦(生成中に移動→戻ると turn_active=false)が
349
+ // 解消し、停止/完了通知も同一プロセスに揃う。同一会話 (threadId 一致) のみ対象。
350
+ if (resumeThreadId) {
351
+ const existing = this._findLiveSessionByCwd(cwd, resumeThreadId)
352
+ if (existing && existing.stream_id !== stream_id) {
353
+ const r = await this._tryReattach(existing, stream_id)
354
+ if (r.status === "reattached") {
355
+ return {
356
+ ...r.info,
357
+ // 案1: reattach は履歴を返さない設計 (r.info.history=[]) だが、cwd reattach は remount
358
+ // 由来で表示がリセットされ得るため、live セッションが保持する高忠実度履歴を返す。
359
+ history: Array.isArray(existing.historyItems) ? existing.historyItems.slice() : [],
360
+ historyTruncated: existing.historyTruncated === true,
361
+ }
362
+ }
363
+ if (r.status === "superseded") {
364
+ const err = new Error("attach superseded by a concurrent reattach")
365
+ err.attachOperation = "reattach"
366
+ err.attachReason = "superseded"
367
+ throw err
368
+ }
369
+ // r.status === "unavailable" → 通常経路 (thread/resume) へフォールスルー。
370
+ }
371
+ }
372
+
335
373
  const client = this._clientFactory({
336
374
  cwd,
337
375
  codexCmd: this.codexCmd,
@@ -349,8 +387,15 @@ export class CodexStreamBridge extends EventEmitter {
349
387
  attachId: attachId || null,
350
388
  client,
351
389
  threadId: null,
390
+ // T06541 major#3: cwd reattach の in-flight 引き当てキー。thread/resume 完了まで threadId は
391
+ // null なので、並行 cold attach が「同一 (cwd, resumeThreadId)」を検出できるよう resume 対象の
392
+ // thread id を **client 生成前 (同期)** に持たせる。これで後続 attach は _findLiveSessionByCwd で
393
+ // この session を引き当て _tryReattach の session.ready 合流に入り、二重 app-server を作らない。
394
+ resumeThreadId: resumeThreadId || null,
352
395
  activeTurnId: null,
353
- pendingApprovals: new Map(), // request_id -> { turnId }
396
+ // request_id -> { turnId, method, params }。major#1: reattach で新 stream へ未応答の承認要求を
397
+ // 再送できるよう、元の method/params も保持する (Claude の _permissionResolvers と同型)。
398
+ pendingApprovals: new Map(),
354
399
  // ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。診断・ログ用。
355
400
  // Stage 5: 回収の可否は detachRequested ではなく lastActivityMs(LRU)と activeTurnId
356
401
  // (busy/stuck)で決める。detach してもすぐには殺さず warm 保持する。
@@ -361,7 +406,22 @@ export class CodexStreamBridge extends EventEmitter {
361
406
  // 通知 (ターン進捗) の両方で更新する。LRU は「最終活性が最も古い非 protected」を退避する。
362
407
  // Date.now() は hub-agent 実行環境で利用可。
363
408
  lastActivityMs: Date.now(),
409
+ // 相方選定・一覧の「新しさ」用の会話ターン境界時刻。lastActivityMs(keepalive 閲覧で
410
+ // 更新される warm/reap 保護用)と役割を分離し、**turn/started・turn/completed のときだけ**
411
+ // 更新する。これにより「見ているだけ(keepalive)」で並び順・ask-sibling 宛先選定が動く
412
+ // 問題を根治する(症状⑨と同じ「read/閲覧時刻を鮮度信号に流用しない」原則)。初期値は
413
+ // attach 時刻=新規セッションは「たった今作った」で上位に来る(閲覧中の古い会話に負けない)。
414
+ lastTurnAtMs: Date.now(),
364
415
  ready: null,
416
+ // T06541 案1(高忠実度履歴の蓄積): cold attach 時の flatten 済み ThreadItem 履歴を
417
+ // 保持し、ライブ item/completed ごとに追記する。cwd reattach(新プロセスを立てない
418
+ // 復帰)時にこれを返すことで、frontend の remount 白紙化を防ぎつつ、rollout JSONL の
419
+ // 生データ(ツールカードが粗い)ではなく app-server 整形済みの高忠実度履歴を維持する。
420
+ historyItems: [],
421
+ historyTruncated: false,
422
+ // major#2: ライブ追記でも cold flatten と同じ「item 数+概算 byte」二重上限を課すため、
423
+ // 蓄積済み履歴の概算 byte 合計を保持する (再計算を避け O(1) で trim 判定)。
424
+ historyBytes: 0,
365
425
  }
366
426
  session.ready = new Promise((resolve) => {
367
427
  settleReady = resolve
@@ -392,6 +452,12 @@ export class CodexStreamBridge extends EventEmitter {
392
452
  : { items: [], truncated: false, total: 0 }
393
453
  const history = flat.items
394
454
  const historyTruncated = flat.truncated
455
+ // T06541 案1: cold attach で得た高忠実度履歴をセッションに保持する。以降ライブ item
456
+ // 完了ごとに追記し、cwd reattach 時にこれを返す(新プロセス thread/resume を再実行しない)。
457
+ // major#2: byte 予算判定を O(1) にするため概算 byte 合計も持つ (flatten は既に byte 上限適用済み)。
458
+ session.historyItems = history.slice()
459
+ session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
460
+ session.historyTruncated = historyTruncated
395
461
  // 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
396
462
  // これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
397
463
  // 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
@@ -455,6 +521,58 @@ export class CodexStreamBridge extends EventEmitter {
455
521
  return null
456
522
  }
457
523
 
524
+ /**
525
+ * T06541: 同一 (cwd, threadId) のライブ session を返す (cwd ベース in-memory reattach 用)。
526
+ * frontend は attach_id を送らないため attachId 経路は発火せず、resume は毎回 thread/resume で
527
+ * 新プロセスを起動していた(別プロセスで生成中のターンを Thread.status で観測できず turn_active
528
+ * が false になる症状⑦の主機序)。resume_thread_id 一致で生きた session を引き当て所有権を付け替え
529
+ * れば、生成中ターン・停止・完了通知・高忠実度履歴が 1 本に揃う。
530
+ * ⚠️ **同一会話 (threadId 一致) のみ** を対象にし、別会話 (threadId 不一致) を誤って奪わない。
531
+ * major#3: thread/resume 完了前 (threadId=null) の in-flight session も、resume 対象 thread が
532
+ * 一致すれば引き当てる。これで並行 cold attach の後続が _tryReattach の session.ready 合流に入り、
533
+ * 同一 thread の app-server が二重生成されるのを防ぐ (Claude の同期索引登録と同じ「空の窓」対策)。
534
+ */
535
+ _findLiveSessionByCwd(cwd, threadId) {
536
+ if (!cwd || !threadId) return null
537
+ for (const s of this.sessions.values()) {
538
+ if (s.dead || s.cwd !== cwd) continue
539
+ // 確定済み thread 一致、または in-flight (未確定) で resume 対象が一致。
540
+ if (s.threadId === threadId) return s
541
+ if (s.threadId === null && s.resumeThreadId === threadId) return s
542
+ }
543
+ return null
544
+ }
545
+
546
+ /**
547
+ * T06541 案1: 完了 ThreadItem を高忠実度履歴 (session.historyItems) に追記する。
548
+ * 表示用バブル時刻 at_ms は completedAtMs (ms epoch) を採る(flattenThreadItems と同じ ms 単位。
549
+ * userMessage はライブでも completedAtMs=ターン冒頭で届くため整合する)。長い会話で無限成長させ
550
+ * ないよう cold flatten と同じ **item 数 (maxItems) + 概算 byte 数 (maxBytes) の二重上限** を課し、
551
+ * 超過分は先頭 (最古) から捨て truncated を立てる (major#2: 巨大ツール出力で reattach payload が
552
+ * 肥大化し T06499 で防いだメモリ増・WS 転送・ブラウザフリーズが再発するのを防ぐ)。
553
+ */
554
+ _appendHistoryItem(session, item, completedAtMs) {
555
+ if (!session || !item || typeof item !== "object") return
556
+ if (!Array.isArray(session.historyItems)) session.historyItems = []
557
+ if (typeof session.historyBytes !== "number") session.historyBytes = 0
558
+ const entry = typeof completedAtMs === "number" ? { ...item, at_ms: completedAtMs } : item
559
+ session.historyItems.push(entry)
560
+ session.historyBytes += estimateItemBytes(entry)
561
+ const maxItems = this._historyLimits.maxItems
562
+ const maxBytes = this._historyLimits.maxBytes
563
+ // 末尾 1 件は必ず残す (flatten と同じ方針)。item 数超過 or byte 超過の間、先頭から捨てる。
564
+ while (
565
+ session.historyItems.length > 1 &&
566
+ ((typeof maxItems === "number" && session.historyItems.length > maxItems) ||
567
+ (typeof maxBytes === "number" && session.historyBytes > maxBytes))
568
+ ) {
569
+ const dropped = session.historyItems.shift()
570
+ session.historyBytes -= estimateItemBytes(dropped)
571
+ if (session.historyBytes < 0) session.historyBytes = 0
572
+ session.historyTruncated = true
573
+ }
574
+ }
575
+
458
576
  /**
459
577
  * 既存ライブ session を新 stream_id へ付け替える (in-memory reattach)。thread が未確定
460
578
  * (thread/start in-flight) なら session.ready を await する (成功=threadId / 失敗=null)。
@@ -488,6 +606,11 @@ export class CodexStreamBridge extends EventEmitter {
488
606
  { stream_id: newStreamId, thread_id: threadId, cwd: session.cwd, reattached: true },
489
607
  "codex session reattached (in-memory)",
490
608
  )
609
+ // major#1: 新 stream へ未応答の承認要求を再送する (Claude の _replayPendingPermissions と同型)。
610
+ // 承認要求は app-server との in-flight callback で、履歴 hydrate には含まれない。旧 stream 宛に
611
+ // 出したカードは remount/端末切替で失われ、応答が来ないとターンが永久 pending になる。新 stream へ
612
+ // 再 emit してカードを復元させる (frontend は request_id で重複排除するので二重化しない)。
613
+ this._replayPendingApprovals(session)
491
614
  // 履歴は返さない: reconnect では frontend が表示を保持している (resetDisplay=false) ため
492
615
  // 再 hydrate 不要。turnActive は session が保持する activeTurnId から権威的に決める。
493
616
  return {
@@ -610,13 +733,21 @@ export class CodexStreamBridge extends EventEmitter {
610
733
  switch (msg.method) {
611
734
  case "turn/started":
612
735
  session.activeTurnId = msg.params?.turn?.id || null
736
+ // 会話ターン境界で「新しさ」を更新(閲覧 keepalive では更新しない)。
737
+ session.lastTurnAtMs = Date.now()
613
738
  break
614
739
  case "turn/completed":
615
740
  session.activeTurnId = null
741
+ session.lastTurnAtMs = Date.now()
616
742
  this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
617
743
  // Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
618
744
  // 長 idle TTL / LRU で扱う(detach 済みでも 7 日 warm 保持)。
619
745
  break
746
+ case "item/completed":
747
+ // T06541 案1: 完了した ThreadItem を高忠実度履歴に追記する(cwd reattach で返す用)。
748
+ // ライブ表示は下の emit がそのまま担う(本 case は履歴蓄積を足すだけで emit は変えない)。
749
+ this._appendHistoryItem(session, msg.params?.item, msg.params?.completedAtMs)
750
+ break
620
751
  default:
621
752
  break
622
753
  }
@@ -630,7 +761,12 @@ export class CodexStreamBridge extends EventEmitter {
630
761
 
631
762
  _onServerRequest(session, msg) {
632
763
  const { stream_id } = session
633
- session.pendingApprovals.set(msg.id, { turnId: session.activeTurnId })
764
+ // major#1: reattach 時に新 stream へ再送できるよう method/params も保持する。
765
+ session.pendingApprovals.set(msg.id, {
766
+ turnId: session.activeTurnId,
767
+ method: msg.method,
768
+ params: msg.params,
769
+ })
634
770
  this.emit("permission", {
635
771
  stream_id,
636
772
  thread_id: session.threadId,
@@ -641,6 +777,25 @@ export class CodexStreamBridge extends EventEmitter {
641
777
  })
642
778
  }
643
779
 
780
+ /**
781
+ * major#1: 未応答の承認要求を現在の stream へ再送する (reattach から呼ぶ)。Claude の
782
+ * _replayPendingPermissions と同型。frontend は request_id で重複排除するため、既に表示済みの
783
+ * 端末でも二重化しない。method/params は _onServerRequest 受信時に保存済み。
784
+ */
785
+ _replayPendingApprovals(session) {
786
+ if (!session || session.pendingApprovals.size === 0) return
787
+ for (const [request_id, info] of session.pendingApprovals) {
788
+ this.emit("permission", {
789
+ stream_id: session.stream_id,
790
+ thread_id: session.threadId,
791
+ cwd: session.cwd,
792
+ request_id,
793
+ method: info.method,
794
+ params: info.params,
795
+ })
796
+ }
797
+ }
798
+
644
799
  // ターン完了時、そのターンに紐づく未解決の承認要求は自動 decline する
645
800
  // (browser が応答する前にターンが打ち切られた/クラッシュ相当のケース。
646
801
  // claude-stream-bridge.mjs の「ターン終了時に未解決分は自動 deny」と同じ設計)。
@@ -833,7 +988,9 @@ export class CodexStreamBridge extends EventEmitter {
833
988
  thread_id: session.threadId || null,
834
989
  busy: !!session.activeTurnId,
835
990
  detached: !!session.detachRequested,
836
- idle_ms: now - (session.lastActivityMs ?? now),
991
+ // idle_ms は「新しさ」の指標(一覧の並び・宛先選定に使う)なので、閲覧 keepalive で
992
+ // 動く lastActivityMs ではなく **会話ターン境界時刻 lastTurnAtMs** を基準にする。
993
+ idle_ms: now - (session.lastTurnAtMs ?? session.lastActivityMs ?? now),
837
994
  })
838
995
  }
839
996
  return out
@@ -879,7 +1036,9 @@ export class CodexStreamBridge extends EventEmitter {
879
1036
  let bestAt = -1
880
1037
  for (const session of this.sessions.values()) {
881
1038
  if (session.dead || session.cwd !== cwd) continue
882
- const at = session.lastActivityMs ?? 0
1039
+ // 宛先選定の「最新」は会話ターン境界時刻で判定する(閲覧 keepalive で動く
1040
+ // lastActivityMs を使うと「見ているだけの古い会話」が本命を追い越す)。
1041
+ const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
883
1042
  if (at >= bestAt) {
884
1043
  bestAt = at
885
1044
  best = session