@cocorograph/hub-agent 0.7.71 → 0.7.73

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.71",
3
+ "version": "0.7.73",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -16,11 +16,15 @@
16
16
  * claude.event (生 SDK イベント素通し) → 'event' (生 notification 素通し、同じ思想)
17
17
  * claude.permission.request → 'permission' (承認/入力要求の生 method+params 素通し)
18
18
  *
19
+ * ## in-memory reattach (Stage4 caller レビューで追加)
20
+ * `attachId` を伴う attach は、同じ attach_id のライブ session を **新 stream_id へ付け替える**
21
+ * (新 app-server を起動しない)。browser の WS 再接続で stream_id が変わっても、fresh 直後
22
+ * (rollout 0件) の会話も含めて復元できる。詳細は attach() の docstring 参照。
23
+ *
19
24
  * Slice 2 のスコープ外 (意図的に見送り、後続スライスで追加する):
20
- * - 複数ブラウザタブでの同一スレッド共有 (Claude の HUB_AGENT_CHAT_SHARED 相当)。
21
- * resumeThreadId が「この hub-agent プロセス内で存命中の別 stream_id のセッション」
22
- * を指す in-memory reattach は今回未対応。resumeThreadId は「未存命のスレッドを
23
- * thread/resume で再ロードする」場合のみ扱う。
25
+ * - 複数ブラウザタブでの **同時** 同一スレッド共有 (Claude の HUB_AGENT_CHAT_SHARED 相当)。
26
+ * in-memory reattach は「1 つの持ち主が WS を張り直す」ケース (単一勝者) であり、複数タブが
27
+ * 同時に同一スレッドを操作する共有はまだ扱わない。
24
28
  * - 画像/添付入力 (テキストのみ)。
25
29
  * - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
26
30
  * (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
@@ -36,6 +40,7 @@ import { EventEmitter } from "node:events"
36
40
 
37
41
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
38
42
  import { getOrCreateSiblingToken } from "./sibling-token.mjs"
43
+ import { codexRolloutPresence } from "./usage.mjs"
39
44
 
40
45
  /**
41
46
  * sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
@@ -67,11 +72,12 @@ function siblingEnvFor(cwd) {
67
72
  // 全ターンで出続ける)。
68
73
  // 予約は detach 済みセッションに限り、発火時にも busy/再活性を再確認すること。
69
74
  // detach 後、非 busy なセッションを生かしておく猶予。claude 側 (7 日) と違い短くする:
70
- // Slice 2 は in-memory reattach 非対応のため、browser の WS 再接続ごとに新 stream_id で
71
- // 新プロセスが起動し、旧プロセスは detach で TTL 待ちに入る。7 日だと再接続チャーンの
72
- // たびに codex app-server プロセスが積み上がる (リーク)。Codex は thread/resume が
73
- // rollout 正本からモデル側コンテキストを完全復元できるため、プロセスを短命にしても
74
- // 会話は失われない (再 attach 時のプロセス起動コストは 1〜2 秒)。ターン実行中は TTL に
75
+ // この TTL は **in-memory reattach の猶予窓** を兼ねる。WS が切れて browser disconnect が
76
+ // codex.detach を送っても、この TTL 内に **同じ attach_id で再接続** すれば _tryReattach が
77
+ // その session を新 stream へ付け替えて拾える (fresh 直後 rollout 0件の会話も復元可)。TTL を
78
+ // 超えて reap されたら、再接続は通常経路 (thread/resume は rollout 正本から復元、または fresh)
79
+ // にフォールスルーする。7 日だと再接続チャーンのたびにプロセスが積み上がる (リーク) ため短命に
80
+ // する。ターン実行中は TTL に
75
81
  // 関わらず完走まで絶対に落とさない (_maybeReapAfterIdle が busy 中は予約しない)。
76
82
  const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
77
83
 
@@ -87,15 +93,61 @@ const ABANDONED_SESSION_TTL_MS =
87
93
 
88
94
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
89
95
 
96
+ // T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
97
+ // 20MB 超まで肥大し得る (実測: メイン repo 540KB が正常なのに対し 20MB のスレッドあり)。
98
+ // thread/resume 応答をそのまま平坦化して browser へ送ると、
99
+ // ① WS へ 20MB の JSON を積む → 転送で数秒フリーズ
100
+ // ② browser 側で全 item を codexHistoryToEvents → reducer merge → 数千バブル描画で
101
+ // メインスレッドがハング (履歴も、その裏で撃つ codex.sessions.request 由来の
102
+ // セッション切替バーも一切出ない = UX の断崖)
103
+ // が起きる。thread/resume 自体はモデル側の会話コンテキスト復元のため全量が必要だが、
104
+ // **browser へ渡す表示用履歴は末尾 N ターン分に絞る** ことで断崖を回避する。上限は
105
+ // 「item 数」と「概算バイト数」の 2 軸で、末尾から詰めてどちらか先に達したら truncated。
106
+ const HISTORY_MAX_ITEMS = Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_ITEMS) || 200
107
+ const HISTORY_MAX_BYTES =
108
+ Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_BYTES) || 1.5 * 1024 * 1024
109
+
90
110
  // Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
91
111
  // turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
92
- function flattenThreadItems(thread) {
112
+ //
113
+ // T06499: 末尾から maxItems / maxBytes の budget 内に収め、超過分は落として truncated=true を
114
+ // 返す。末尾優先なのは「直近の会話が最も価値が高く、古い履歴は resume されたモデル側が保持して
115
+ // いる」ため。budget を末尾から積むので、20MB スレッドでも全 item を stringify せず早期に打ち切る
116
+ // (kept が budget に達した時点で残りの巨大な古い item には触れない)。
117
+ // @returns {{items: object[], truncated: boolean, total: number}}
118
+ function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = HISTORY_MAX_BYTES } = {}) {
93
119
  const turns = Array.isArray(thread?.turns) ? thread.turns : []
94
- const items = []
120
+ const all = []
95
121
  for (const turn of turns) {
96
- if (Array.isArray(turn?.items)) items.push(...turn.items)
122
+ if (Array.isArray(turn?.items)) all.push(...turn.items)
123
+ }
124
+ const total = all.length
125
+ const kept = []
126
+ let bytes = 0
127
+ let truncated = false
128
+ // 末尾 (最新) から詰める。budget 超過で打ち切り、truncated を立てる。
129
+ for (let i = all.length - 1; i >= 0; i--) {
130
+ if (kept.length >= maxItems) {
131
+ truncated = true
132
+ break
133
+ }
134
+ const item = all[i]
135
+ let size = 0
136
+ try {
137
+ size = JSON.stringify(item)?.length || 0
138
+ } catch {
139
+ size = 0
140
+ }
141
+ // 最低 1 件は必ず入れる (単一 item が maxBytes を超えても末尾 1 件は見せる)。
142
+ if (kept.length > 0 && bytes + size > maxBytes) {
143
+ truncated = true
144
+ break
145
+ }
146
+ kept.push(item)
147
+ bytes += size
97
148
  }
98
- return items
149
+ kept.reverse() // 末尾から積んだので時系列順へ戻す。
150
+ return { items: kept, truncated, total }
99
151
  }
100
152
 
101
153
  /**
@@ -178,34 +230,90 @@ function userInputFromMessage(message) {
178
230
  }
179
231
 
180
232
  export class CodexStreamBridge extends EventEmitter {
181
- constructor({ codexCmd, logger, clientFactory, detachTtlMs } = {}) {
233
+ constructor({
234
+ codexCmd,
235
+ logger,
236
+ clientFactory,
237
+ detachTtlMs,
238
+ rolloutPresence,
239
+ historyMaxItems,
240
+ historyMaxBytes,
241
+ } = {}) {
182
242
  super()
183
243
  this.codexCmd = codexCmd
184
244
  this.logger = logger
185
245
  this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
186
246
  // テストから TTL を短縮できるよう注入可能にする (既定は 7 日)。
187
247
  this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
248
+ // #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
249
+ this._rolloutPresence = rolloutPresence || codexRolloutPresence
250
+ // T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
251
+ this._historyLimits = {
252
+ maxItems: historyMaxItems ?? HISTORY_MAX_ITEMS,
253
+ maxBytes: historyMaxBytes ?? HISTORY_MAX_BYTES,
254
+ }
188
255
  /** @type {Map<string, object>} stream_id -> session record */
189
256
  this.sessions = new Map()
190
257
  }
191
258
 
192
259
  /**
260
+ * Codex セッションへ attach する。
261
+ *
262
+ * ## in-memory reattach (Stage4 caller レビュー major #1)
263
+ * `attachId` は「1 つの attach 試行」の安定 id。browser の WS が attach〜利用中に切れて
264
+ * **同じ attach_id で再接続**してきたら、新しい app-server を起動して thread/resume するのでは
265
+ * なく、**既にメモリ上で生きている session をこの新しい stream_id へ付け替える**(所有権と
266
+ * イベントルーティングを移す)。これにより「fresh thread を作成したが初回入力前で rollout が
267
+ * まだ 0 件」の状態でも復元でき、二重 thread/start も起きない (rollout 依存の thread/resume は
268
+ * このケースを復元できない)。live session が既に reap 済み / hub-agent 再起動後などで見つから
269
+ * なければ、通常の attach (thread/start | thread/resume) にフォールスルーする。
270
+ *
193
271
  * @returns {Promise<{resuming: boolean, threadId: string, history: object[],
194
- * threadStatus: string|null, lastTurnStatus: string|null, turnActive: boolean}>}
272
+ * historyTruncated: boolean, threadStatus: string|null, lastTurnStatus: string|null,
273
+ * turnActive: boolean}>}
195
274
  */
196
- async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
275
+ async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId, attachId }) {
197
276
  if (this.sessions.has(stream_id)) {
198
277
  throw new Error(`stream_id already attached: ${stream_id}`)
199
278
  }
279
+ // #1: 同じ attach_id のライブ session があれば in-memory reattach を試みる。
280
+ if (attachId) {
281
+ const existing = this._findLiveSessionByAttachId(attachId)
282
+ if (existing && existing.stream_id !== stream_id) {
283
+ const r = await this._tryReattach(existing, stream_id)
284
+ if (r.status === "reattached") return r.info
285
+ if (r.status === "superseded") {
286
+ // 別 reattach が勝って session を奪った (concurrent reconnect)。**通常経路へ落とさない**
287
+ // — 落とすと敗者が別 thread を thread/start してしまい I2 (fresh 最大1回) を再び破る /
288
+ // 同一 thread の app-server が複数化する (major #1 ラウンド5)。二重 thread 副作用を作らず
289
+ // superseded として失敗させる (実運用では同 attach_id の同時 attach は起きない防御。
290
+ // 起きても実 thread/start は勝者の 1 本だけに保たれる)。
291
+ const err = new Error("attach superseded by a concurrent reattach")
292
+ err.attachOperation = "reattach"
293
+ err.attachReason = "superseded"
294
+ throw err
295
+ }
296
+ // r.status === "unavailable": session が (await 中に) 死んだ / first の thread 確定に失敗。
297
+ // = ライブ session はもう無い → 通常経路 (thread/resume or fresh) へフォールスルーしてよい
298
+ // (二重生成の相手が居ないため)。
299
+ }
300
+ }
301
+
200
302
  const client = this._clientFactory({
201
303
  cwd,
202
304
  codexCmd: this.codexCmd,
203
305
  logger: this.logger,
204
306
  env: siblingEnvFor(cwd),
205
307
  })
308
+ // #2: この session の thread 確定を待つ単一 promise。reattach の待ち合わせ (in-flight) に使う。
309
+ // **成功で threadId、失敗で null を resolve** する (reject しない = 待ち手がいなくても
310
+ // unhandled rejection を作らず、複数の reconnect が同じ promise を安全に await できる)。
311
+ let settleReady
206
312
  const session = {
207
313
  stream_id,
208
314
  cwd,
315
+ // #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
316
+ attachId: attachId || null,
209
317
  client,
210
318
  threadId: null,
211
319
  activeTurnId: null,
@@ -219,14 +327,22 @@ export class CodexStreamBridge extends EventEmitter {
219
327
  // 放棄セッション reaper (gcAbandonedSessions) 用の最終活性時刻。browser 入力と
220
328
  // app-server 通知 (ターン進捗) の両方で更新する。Date.now() は hub-agent 実行環境で利用可。
221
329
  lastActivityMs: Date.now(),
330
+ ready: null,
222
331
  }
332
+ session.ready = new Promise((resolve) => {
333
+ settleReady = resolve
334
+ })
223
335
  this.sessions.set(stream_id, session)
224
336
  this._wireClient(session)
225
337
  client.start()
226
338
 
339
+ const resuming = !!resumeThreadId
340
+ // #3: initialize と thread op を分離し、thread op に到達したかを記録する。initialize / config
341
+ // / MCP 起動の失敗 (thread op 未到達) は attach 失敗分類の対象外にする (pointer を消さない)。
342
+ let reachedThreadOp = false
227
343
  try {
228
344
  await client.request("initialize", { clientInfo: CLIENT_INFO })
229
- const resuming = !!resumeThreadId
345
+ reachedThreadOp = true
230
346
  const startParams = { cwd, model: model || undefined, sandbox: sandbox || undefined }
231
347
  if (effort) startParams.effort = effort
232
348
  if (approvalPolicy) startParams.approvalPolicy = approvalPolicy
@@ -236,7 +352,12 @@ export class CodexStreamBridge extends EventEmitter {
236
352
  session.threadId = result?.thread?.id
237
353
  // 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
238
354
  // (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
239
- const history = resuming ? flattenThreadItems(result?.thread) : []
355
+ // T06499: 巨大スレッドは末尾 N ターンに絞る (truncated=true なら browser がバナー表示)。
356
+ const flat = resuming
357
+ ? flattenThreadItems(result?.thread, this._historyLimits)
358
+ : { items: [], truncated: false, total: 0 }
359
+ const history = flat.items
360
+ const historyTruncated = flat.truncated
240
361
  // 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
241
362
  // これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
242
363
  // 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
@@ -246,6 +367,10 @@ export class CodexStreamBridge extends EventEmitter {
246
367
  // Codex が「ターン実行中」と権威的に報告した場合のみ activeTurnId を復元する
247
368
  // (通常の fresh resume では null = 次入力が turn/start になり固着しない)。
248
369
  session.activeTurnId = turnState.activeTurnId
370
+ // minor #3: session の権威状態 (activeTurnId) を **設定し終えてから** reattach 待ちへ
371
+ // resolve する。先に resolve すると、in-flight 中の reconnect が activeTurnId 未設定
372
+ // (turnActive=false) の状態で reattach し得るため、初期化完了後にスナップショットで通知する。
373
+ settleReady(session.threadId || null)
249
374
  // セッションのライフサイクルは必ずログに残す (2026-07-17: attach/detach/reap/exit の
250
375
  // 記録が皆無で「なぜ消えたか」を事後に辿れなかった)。同一 cwd に何本のセッションが
251
376
  // 積み上がっているかも併記し、ゾンビ蓄積をログだけで検知できるようにする。
@@ -257,6 +382,10 @@ export class CodexStreamBridge extends EventEmitter {
257
382
  resuming,
258
383
  thread_status: turnState.threadStatus,
259
384
  live_for_cwd: this.describeLiveSessions(cwd).length,
385
+ // T06499: 巨大スレッド hydrate の可視化 (何件中何件を返したか)。
386
+ history_items: history.length,
387
+ history_total: flat.total,
388
+ history_truncated: historyTruncated,
260
389
  },
261
390
  "codex session attached",
262
391
  )
@@ -264,6 +393,7 @@ export class CodexStreamBridge extends EventEmitter {
264
393
  resuming,
265
394
  threadId: session.threadId,
266
395
  history,
396
+ historyTruncated,
267
397
  threadStatus: turnState.threadStatus,
268
398
  lastTurnStatus: turnState.lastTurnStatus,
269
399
  turnActive: turnState.turnActive,
@@ -271,10 +401,103 @@ export class CodexStreamBridge extends EventEmitter {
271
401
  } catch (err) {
272
402
  this.sessions.delete(stream_id)
273
403
  client.stop()
404
+ settleReady(null) // reattach 待ちへ「失敗」を通知 (reject しない)
405
+ // #3/#4: thread op に到達した失敗だけ分類する (initialize 失敗は分類しない)。
406
+ if (reachedThreadOp) await this._classifyAttachFailure(err, { resuming, resumeThreadId })
274
407
  throw err
275
408
  }
276
409
  }
277
410
 
411
+ /** attach_id に一致するライブ session を返す (in-memory reattach 用。無ければ null)。 */
412
+ _findLiveSessionByAttachId(attachId) {
413
+ if (!attachId) return null
414
+ for (const s of this.sessions.values()) {
415
+ if (!s.dead && s.attachId === attachId) return s
416
+ }
417
+ return null
418
+ }
419
+
420
+ /**
421
+ * 既存ライブ session を新 stream_id へ付け替える (in-memory reattach)。thread が未確定
422
+ * (thread/start in-flight) なら session.ready を await する (成功=threadId / 失敗=null)。
423
+ * 付け替えられなければ null を返し、呼び出し側 attach() は通常経路へフォールスルーする。
424
+ */
425
+ async _tryReattach(session, newStreamId) {
426
+ // ⚠️ 付け替え前の stream_id を **await の前に capture** する (major #1: 同じ attach_id へ
427
+ // 複数 reconnect が session.ready 待ちで重なったとき、`session.stream_id` を await 後に
428
+ // 読むと勝者が既に付け替えた現在値を追ってしまい「別 stream へ移動済み」を検出できず、
429
+ // 敗者も成功してしまう。capture 値で CAS することで **単一勝者**にする)。
430
+ // 戻り値 status:
431
+ // "reattached" - この呼び出しが session を newStreamId へ付け替えた (info 同梱)。
432
+ // "superseded" - 別 reattach が勝って session を奪った (session は生きている)。呼び出し側は
433
+ // **通常経路に落とさず** 失敗させる (二重 thread を作らない)。
434
+ // "unavailable"- session が死んだ / first の thread 確定に失敗 (ライブ session 無し)。
435
+ // 呼び出し側は通常経路へフォールスルーしてよい。
436
+ const originalStreamId = session.stream_id
437
+ let threadId = session.threadId
438
+ if (!threadId && session.ready) threadId = await session.ready
439
+ // first の thread op が失敗 / session が死んだ = ライブ session はもう無い。
440
+ if (!threadId || session.dead) return { status: "unavailable" }
441
+ // await 中に別 reattach が勝って session を移動した = concurrent の敗者。
442
+ if (this.sessions.get(originalStreamId) !== session) return { status: "superseded" }
443
+ if (this.sessions.has(newStreamId)) return { status: "superseded" } // 新 stream 側が別 session 保持 (異常)
444
+ this.sessions.delete(originalStreamId)
445
+ session.stream_id = newStreamId
446
+ session.detachRequested = false
447
+ this._cancelIdleReap(session, "reattach")
448
+ session.lastActivityMs = Date.now()
449
+ this.sessions.set(newStreamId, session)
450
+ this.logger?.info?.(
451
+ { stream_id: newStreamId, thread_id: threadId, cwd: session.cwd, reattached: true },
452
+ "codex session reattached (in-memory)",
453
+ )
454
+ // 履歴は返さない: reconnect では frontend が表示を保持している (resetDisplay=false) ため
455
+ // 再 hydrate 不要。turnActive は session が保持する activeTurnId から権威的に決める。
456
+ return {
457
+ status: "reattached",
458
+ info: {
459
+ resuming: true,
460
+ threadId,
461
+ history: [],
462
+ // reconnect の in-memory reattach は表示を保持しているので履歴を返さない = 常に非 truncated。
463
+ historyTruncated: false,
464
+ threadStatus: session.activeTurnId ? "active" : "idle",
465
+ lastTurnStatus: null,
466
+ turnActive: !!session.activeTurnId,
467
+ },
468
+ }
469
+ }
470
+
471
+ /**
472
+ * resume/start 失敗を frontend が扱える reason に分類する (Stage4 caller レビュー major #3/#4)。
473
+ * - resume: **timeout を最優先** (thread/resume が timeout なら rollout 有無に関係なく timeout)。
474
+ * それ以外は rollout の tri-state (present/absent/unknown) で判定し、**absent のときだけ**
475
+ * thread_not_found (frontend が pointer を compare-delete してよい)。unknown/present は
476
+ * resume_error (有効 pointer を保持する)。
477
+ * - start (fresh): thread_not_found はあり得ない。timeout か start_error。
478
+ * initialize 段階の失敗はここに来ない (呼び出し側が reachedThreadOp で gate 済み)。
479
+ */
480
+ async _classifyAttachFailure(err, { resuming, resumeThreadId }) {
481
+ if (resuming) {
482
+ err.attachOperation = "resume"
483
+ err.attachThreadId = resumeThreadId
484
+ if (err.reason === "timeout") {
485
+ err.attachReason = "timeout"
486
+ return
487
+ }
488
+ let presence = "unknown"
489
+ try {
490
+ presence = await this._rolloutPresence(resumeThreadId)
491
+ } catch {
492
+ presence = "unknown"
493
+ }
494
+ err.attachReason = presence === "absent" ? "thread_not_found" : "resume_error"
495
+ } else {
496
+ err.attachOperation = "start"
497
+ err.attachReason = err.reason === "timeout" ? "timeout" : "start_error"
498
+ }
499
+ }
500
+
278
501
  /**
279
502
  * cwd 配下の Codex thread 一覧を返す (Cockpit Codex 両対応 セッションピッカー/最新
280
503
  * セッション自動選択用。claude 側 listSessions と対称)。
@@ -317,12 +540,18 @@ export class CodexStreamBridge extends EventEmitter {
317
540
  }
318
541
 
319
542
  _wireClient(session) {
320
- const { client, stream_id } = session
543
+ const { client } = session
321
544
  client.on("notification", (msg) => this._onNotification(session, msg))
322
545
  client.on("request", (msg) => this._onServerRequest(session, msg))
323
546
  client.on("exit", (info) => this._onClientExit(session, info))
324
547
  client.on("error", (err) => {
325
- this.emit("error", { stream_id, thread_id: session.threadId, error: err.message || String(err) })
548
+ // ⚠️ session.stream_id を **都度読む** (in-memory reattach で付け替わるため。wire 時に
549
+ // 分割代入で固定すると reattach 後に旧 stream へ error を emit してしまう)。
550
+ this.emit("error", {
551
+ stream_id: session.stream_id,
552
+ thread_id: session.threadId,
553
+ error: err.message || String(err),
554
+ })
326
555
  })
327
556
  }
328
557
 
package/src/main.mjs CHANGED
@@ -4197,6 +4197,9 @@ async function dispatch(msg, ctx) {
4197
4197
  approvalPolicy: msg.approval_policy || undefined,
4198
4198
  sandbox: msg.sandbox || undefined,
4199
4199
  resumeThreadId,
4200
+ // Stage4 caller レビュー major #1: attach 試行の安定 id。同じ attach_id で再接続
4201
+ // すると bridge が rebind し、fresh の二重生成を防ぐ (browser の stream_id は都度変わる)。
4202
+ attachId: msg.attach_id || undefined,
4200
4203
  })
4201
4204
  ctx.client.send({
4202
4205
  type: "codex.ready",
@@ -4207,6 +4210,9 @@ async function dispatch(msg, ctx) {
4207
4210
  // consumers.py の per-stream relay は content-blind なので backend 側の
4208
4211
  // 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
4209
4212
  history: info.history || [],
4213
+ // T06499: 巨大スレッドで末尾 N ターンに絞った場合 true。browser は
4214
+ // 「以前の履歴は省略されています」バナーを出す (会話は resume 済みで継続可)。
4215
+ history_truncated: info.historyTruncated === true,
4210
4216
  // 案C (権威的ターン状態): フロントは会話末尾からの推測 (deriveTurnActive
4211
4217
  // FromHistory) をやめ、Codex が明示する Thread.status で turnActive を決める。
4212
4218
  // resume 後は新プロセスで実行中ターンが無く thread_status='idle' が返るため、
@@ -4223,7 +4229,11 @@ async function dispatch(msg, ctx) {
4223
4229
  // - fresh (resume 無し): ゼロ値 + 確定済み threadId で即時 push (症状④)。
4224
4230
  // - resume: codexUsageForThread で実 context% を解決し即時 push (症状③)。
4225
4231
  if (resolvedCwd) {
4226
- if (!resumeThreadId) {
4232
+ // Stage4 caller レビュー major #6: fresh/resume の分岐は **実際の attach 結果**
4233
+ // (info.resuming) で判定する。in-memory reattach は元 msg.resume_thread_id が空
4234
+ // (fresh 再接続) でも既存 thread への復帰 = info.resuming=true なので、resume 側の
4235
+ // 実 context% 解決に載せる (fresh 扱いで context を 0 に戻さない)。
4236
+ if (!info.resuming) {
4227
4237
  clearChatSignal(resolvedCwd, "codex")
4228
4238
  recordChatActivity(resolvedCwd, "codex", {
4229
4239
  status: "waiting",
@@ -4272,7 +4282,14 @@ async function dispatch(msg, ctx) {
4272
4282
  // S3 Codex 堅牢化: request() タイムアウト (codex-appserver-client.mjs) は
4273
4283
  // err.reason='timeout' を付与する。frontend が理由別の文言/表示を出し分け
4274
4284
  // られるよう素通しする (無ければ undefined = キー自体を省略)。
4275
- ...(err.reason ? { reason: err.reason } : {}),
4285
+ // Stage4 caller レビュー major #3: attach 失敗は bridge が operation (resume|start) と
4286
+ // reason (thread_not_found|timeout|resume_error|start_error) を分類する。frontend は
4287
+ // operation=resume かつ reason=thread_not_found のときだけ pointer を修復して既存
4288
+ // thread へ fallback する (I5)。err.attachReason があればそれを優先する。
4289
+ ...(err.attachReason || err.reason
4290
+ ? { reason: err.attachReason || err.reason }
4291
+ : {}),
4292
+ ...(err.attachOperation ? { operation: err.attachOperation } : {}),
4276
4293
  })
4277
4294
  }
4278
4295
  return
package/src/usage.mjs CHANGED
@@ -1366,6 +1366,67 @@ export async function findCodexRolloutFile(threadId, { lookbackDays = 9, now = D
1366
1366
  return scanned
1367
1367
  }
1368
1368
 
1369
+ /**
1370
+ * threadId の rollout ファイルの存在を **tri-state** で厳格判定する
1371
+ * (Cockpit Stage4 caller レビュー major #4)。`findCodexRolloutFile` は全 readdir エラーを
1372
+ * 握って null(不在扱い)にするため、EACCES/EIO 等の「走査できなかった」を「存在しない」と
1373
+ * 誤認し、有効な pointer を compare-delete してしまう。ここでは:
1374
+ * - "present": rollout ファイルを実際に見つけた。
1375
+ * - "absent" : 全走査が完了し、readdir 失敗は ENOENT(=そのシャードがまだ無い)だけで、
1376
+ * ファイルが無かった。→ 恒久不在と判断してよい (thread_not_found)。
1377
+ * - "unknown": 途中の readdir が ENOENT 以外 (EACCES/EIO 等) で失敗した / base 自体が読めない。
1378
+ * → 走査不能なので「不在」と断定しない (pointer を保持する)。
1379
+ * @returns {Promise<"present"|"absent"|"unknown">}
1380
+ */
1381
+ export async function codexRolloutPresence(threadId) {
1382
+ if (!threadId) return "unknown"
1383
+ // メモ命中で present 即返し(findCodexRolloutFile と同じメモを共有)。
1384
+ const memoed = _codexRolloutPathMemo.get(threadId)
1385
+ if (memoed) {
1386
+ try {
1387
+ await fs.stat(memoed)
1388
+ return "present"
1389
+ } catch {
1390
+ _codexRolloutPathMemo.delete(threadId)
1391
+ }
1392
+ }
1393
+ const base = codexSessionsDir()
1394
+ const suffix = `-${threadId}.jsonl`
1395
+ let sawUnknownError = false
1396
+ const readdirSafe = async (dir) => {
1397
+ try {
1398
+ return await fs.readdir(dir)
1399
+ } catch (err) {
1400
+ // ENOENT = そのディレクトリがまだ無い(正常な「そこには無い」)。それ以外は走査不能。
1401
+ if (err?.code !== "ENOENT") sawUnknownError = true
1402
+ return null
1403
+ }
1404
+ }
1405
+ const years = await readdirSafe(base)
1406
+ if (years !== null) {
1407
+ for (const y of years.filter((s) => /^\d{4}$/.test(s)).sort().reverse()) {
1408
+ const months = await readdirSafe(path.join(base, y))
1409
+ if (!months) continue
1410
+ for (const m of months.sort().reverse()) {
1411
+ const days = await readdirSafe(path.join(base, y, m))
1412
+ if (!days) continue
1413
+ for (const d of days.sort().reverse()) {
1414
+ const dDir = path.join(base, y, m, d)
1415
+ const files = await readdirSafe(dDir)
1416
+ if (!files) continue
1417
+ const match = files.find((f) => f.endsWith(suffix))
1418
+ if (match) {
1419
+ _codexRolloutPathMemo.set(threadId, path.join(dDir, match))
1420
+ return "present"
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ // ファイルは見つからなかった。走査不能エラーがあったなら「不在」と断定しない。
1427
+ return sawUnknownError ? "unknown" : "absent"
1428
+ }
1429
+
1369
1430
  /** P5(perf): rollout ファイルの token_count 抽出結果を mtime+size でメモ化。 */
1370
1431
  const _codexTokenCountMemo = new Map() // fp → { mtimeMs, size, result }
1371
1432