@cocorograph/hub-agent 0.7.70 → 0.7.72

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.70",
3
+ "version": "0.7.72",
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
 
@@ -178,34 +184,76 @@ function userInputFromMessage(message) {
178
184
  }
179
185
 
180
186
  export class CodexStreamBridge extends EventEmitter {
181
- constructor({ codexCmd, logger, clientFactory, detachTtlMs } = {}) {
187
+ constructor({ codexCmd, logger, clientFactory, detachTtlMs, rolloutPresence } = {}) {
182
188
  super()
183
189
  this.codexCmd = codexCmd
184
190
  this.logger = logger
185
191
  this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
186
192
  // テストから TTL を短縮できるよう注入可能にする (既定は 7 日)。
187
193
  this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
194
+ // #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
195
+ this._rolloutPresence = rolloutPresence || codexRolloutPresence
188
196
  /** @type {Map<string, object>} stream_id -> session record */
189
197
  this.sessions = new Map()
190
198
  }
191
199
 
192
200
  /**
201
+ * Codex セッションへ attach する。
202
+ *
203
+ * ## in-memory reattach (Stage4 caller レビュー major #1)
204
+ * `attachId` は「1 つの attach 試行」の安定 id。browser の WS が attach〜利用中に切れて
205
+ * **同じ attach_id で再接続**してきたら、新しい app-server を起動して thread/resume するのでは
206
+ * なく、**既にメモリ上で生きている session をこの新しい stream_id へ付け替える**(所有権と
207
+ * イベントルーティングを移す)。これにより「fresh thread を作成したが初回入力前で rollout が
208
+ * まだ 0 件」の状態でも復元でき、二重 thread/start も起きない (rollout 依存の thread/resume は
209
+ * このケースを復元できない)。live session が既に reap 済み / hub-agent 再起動後などで見つから
210
+ * なければ、通常の attach (thread/start | thread/resume) にフォールスルーする。
211
+ *
193
212
  * @returns {Promise<{resuming: boolean, threadId: string, history: object[],
194
213
  * threadStatus: string|null, lastTurnStatus: string|null, turnActive: boolean}>}
195
214
  */
196
- async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
215
+ async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId, attachId }) {
197
216
  if (this.sessions.has(stream_id)) {
198
217
  throw new Error(`stream_id already attached: ${stream_id}`)
199
218
  }
219
+ // #1: 同じ attach_id のライブ session があれば in-memory reattach を試みる。
220
+ if (attachId) {
221
+ const existing = this._findLiveSessionByAttachId(attachId)
222
+ if (existing && existing.stream_id !== stream_id) {
223
+ const r = await this._tryReattach(existing, stream_id)
224
+ if (r.status === "reattached") return r.info
225
+ if (r.status === "superseded") {
226
+ // 別 reattach が勝って session を奪った (concurrent reconnect)。**通常経路へ落とさない**
227
+ // — 落とすと敗者が別 thread を thread/start してしまい I2 (fresh 最大1回) を再び破る /
228
+ // 同一 thread の app-server が複数化する (major #1 ラウンド5)。二重 thread 副作用を作らず
229
+ // superseded として失敗させる (実運用では同 attach_id の同時 attach は起きない防御。
230
+ // 起きても実 thread/start は勝者の 1 本だけに保たれる)。
231
+ const err = new Error("attach superseded by a concurrent reattach")
232
+ err.attachOperation = "reattach"
233
+ err.attachReason = "superseded"
234
+ throw err
235
+ }
236
+ // r.status === "unavailable": session が (await 中に) 死んだ / first の thread 確定に失敗。
237
+ // = ライブ session はもう無い → 通常経路 (thread/resume or fresh) へフォールスルーしてよい
238
+ // (二重生成の相手が居ないため)。
239
+ }
240
+ }
241
+
200
242
  const client = this._clientFactory({
201
243
  cwd,
202
244
  codexCmd: this.codexCmd,
203
245
  logger: this.logger,
204
246
  env: siblingEnvFor(cwd),
205
247
  })
248
+ // #2: この session の thread 確定を待つ単一 promise。reattach の待ち合わせ (in-flight) に使う。
249
+ // **成功で threadId、失敗で null を resolve** する (reject しない = 待ち手がいなくても
250
+ // unhandled rejection を作らず、複数の reconnect が同じ promise を安全に await できる)。
251
+ let settleReady
206
252
  const session = {
207
253
  stream_id,
208
254
  cwd,
255
+ // #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
256
+ attachId: attachId || null,
209
257
  client,
210
258
  threadId: null,
211
259
  activeTurnId: null,
@@ -219,14 +267,22 @@ export class CodexStreamBridge extends EventEmitter {
219
267
  // 放棄セッション reaper (gcAbandonedSessions) 用の最終活性時刻。browser 入力と
220
268
  // app-server 通知 (ターン進捗) の両方で更新する。Date.now() は hub-agent 実行環境で利用可。
221
269
  lastActivityMs: Date.now(),
270
+ ready: null,
222
271
  }
272
+ session.ready = new Promise((resolve) => {
273
+ settleReady = resolve
274
+ })
223
275
  this.sessions.set(stream_id, session)
224
276
  this._wireClient(session)
225
277
  client.start()
226
278
 
279
+ const resuming = !!resumeThreadId
280
+ // #3: initialize と thread op を分離し、thread op に到達したかを記録する。initialize / config
281
+ // / MCP 起動の失敗 (thread op 未到達) は attach 失敗分類の対象外にする (pointer を消さない)。
282
+ let reachedThreadOp = false
227
283
  try {
228
284
  await client.request("initialize", { clientInfo: CLIENT_INFO })
229
- const resuming = !!resumeThreadId
285
+ reachedThreadOp = true
230
286
  const startParams = { cwd, model: model || undefined, sandbox: sandbox || undefined }
231
287
  if (effort) startParams.effort = effort
232
288
  if (approvalPolicy) startParams.approvalPolicy = approvalPolicy
@@ -246,6 +302,10 @@ export class CodexStreamBridge extends EventEmitter {
246
302
  // Codex が「ターン実行中」と権威的に報告した場合のみ activeTurnId を復元する
247
303
  // (通常の fresh resume では null = 次入力が turn/start になり固着しない)。
248
304
  session.activeTurnId = turnState.activeTurnId
305
+ // minor #3: session の権威状態 (activeTurnId) を **設定し終えてから** reattach 待ちへ
306
+ // resolve する。先に resolve すると、in-flight 中の reconnect が activeTurnId 未設定
307
+ // (turnActive=false) の状態で reattach し得るため、初期化完了後にスナップショットで通知する。
308
+ settleReady(session.threadId || null)
249
309
  // セッションのライフサイクルは必ずログに残す (2026-07-17: attach/detach/reap/exit の
250
310
  // 記録が皆無で「なぜ消えたか」を事後に辿れなかった)。同一 cwd に何本のセッションが
251
311
  // 積み上がっているかも併記し、ゾンビ蓄積をログだけで検知できるようにする。
@@ -271,10 +331,101 @@ export class CodexStreamBridge extends EventEmitter {
271
331
  } catch (err) {
272
332
  this.sessions.delete(stream_id)
273
333
  client.stop()
334
+ settleReady(null) // reattach 待ちへ「失敗」を通知 (reject しない)
335
+ // #3/#4: thread op に到達した失敗だけ分類する (initialize 失敗は分類しない)。
336
+ if (reachedThreadOp) await this._classifyAttachFailure(err, { resuming, resumeThreadId })
274
337
  throw err
275
338
  }
276
339
  }
277
340
 
341
+ /** attach_id に一致するライブ session を返す (in-memory reattach 用。無ければ null)。 */
342
+ _findLiveSessionByAttachId(attachId) {
343
+ if (!attachId) return null
344
+ for (const s of this.sessions.values()) {
345
+ if (!s.dead && s.attachId === attachId) return s
346
+ }
347
+ return null
348
+ }
349
+
350
+ /**
351
+ * 既存ライブ session を新 stream_id へ付け替える (in-memory reattach)。thread が未確定
352
+ * (thread/start in-flight) なら session.ready を await する (成功=threadId / 失敗=null)。
353
+ * 付け替えられなければ null を返し、呼び出し側 attach() は通常経路へフォールスルーする。
354
+ */
355
+ async _tryReattach(session, newStreamId) {
356
+ // ⚠️ 付け替え前の stream_id を **await の前に capture** する (major #1: 同じ attach_id へ
357
+ // 複数 reconnect が session.ready 待ちで重なったとき、`session.stream_id` を await 後に
358
+ // 読むと勝者が既に付け替えた現在値を追ってしまい「別 stream へ移動済み」を検出できず、
359
+ // 敗者も成功してしまう。capture 値で CAS することで **単一勝者**にする)。
360
+ // 戻り値 status:
361
+ // "reattached" - この呼び出しが session を newStreamId へ付け替えた (info 同梱)。
362
+ // "superseded" - 別 reattach が勝って session を奪った (session は生きている)。呼び出し側は
363
+ // **通常経路に落とさず** 失敗させる (二重 thread を作らない)。
364
+ // "unavailable"- session が死んだ / first の thread 確定に失敗 (ライブ session 無し)。
365
+ // 呼び出し側は通常経路へフォールスルーしてよい。
366
+ const originalStreamId = session.stream_id
367
+ let threadId = session.threadId
368
+ if (!threadId && session.ready) threadId = await session.ready
369
+ // first の thread op が失敗 / session が死んだ = ライブ session はもう無い。
370
+ if (!threadId || session.dead) return { status: "unavailable" }
371
+ // await 中に別 reattach が勝って session を移動した = concurrent の敗者。
372
+ if (this.sessions.get(originalStreamId) !== session) return { status: "superseded" }
373
+ if (this.sessions.has(newStreamId)) return { status: "superseded" } // 新 stream 側が別 session 保持 (異常)
374
+ this.sessions.delete(originalStreamId)
375
+ session.stream_id = newStreamId
376
+ session.detachRequested = false
377
+ this._cancelIdleReap(session, "reattach")
378
+ session.lastActivityMs = Date.now()
379
+ this.sessions.set(newStreamId, session)
380
+ this.logger?.info?.(
381
+ { stream_id: newStreamId, thread_id: threadId, cwd: session.cwd, reattached: true },
382
+ "codex session reattached (in-memory)",
383
+ )
384
+ // 履歴は返さない: reconnect では frontend が表示を保持している (resetDisplay=false) ため
385
+ // 再 hydrate 不要。turnActive は session が保持する activeTurnId から権威的に決める。
386
+ return {
387
+ status: "reattached",
388
+ info: {
389
+ resuming: true,
390
+ threadId,
391
+ history: [],
392
+ threadStatus: session.activeTurnId ? "active" : "idle",
393
+ lastTurnStatus: null,
394
+ turnActive: !!session.activeTurnId,
395
+ },
396
+ }
397
+ }
398
+
399
+ /**
400
+ * resume/start 失敗を frontend が扱える reason に分類する (Stage4 caller レビュー major #3/#4)。
401
+ * - resume: **timeout を最優先** (thread/resume が timeout なら rollout 有無に関係なく timeout)。
402
+ * それ以外は rollout の tri-state (present/absent/unknown) で判定し、**absent のときだけ**
403
+ * thread_not_found (frontend が pointer を compare-delete してよい)。unknown/present は
404
+ * resume_error (有効 pointer を保持する)。
405
+ * - start (fresh): thread_not_found はあり得ない。timeout か start_error。
406
+ * initialize 段階の失敗はここに来ない (呼び出し側が reachedThreadOp で gate 済み)。
407
+ */
408
+ async _classifyAttachFailure(err, { resuming, resumeThreadId }) {
409
+ if (resuming) {
410
+ err.attachOperation = "resume"
411
+ err.attachThreadId = resumeThreadId
412
+ if (err.reason === "timeout") {
413
+ err.attachReason = "timeout"
414
+ return
415
+ }
416
+ let presence = "unknown"
417
+ try {
418
+ presence = await this._rolloutPresence(resumeThreadId)
419
+ } catch {
420
+ presence = "unknown"
421
+ }
422
+ err.attachReason = presence === "absent" ? "thread_not_found" : "resume_error"
423
+ } else {
424
+ err.attachOperation = "start"
425
+ err.attachReason = err.reason === "timeout" ? "timeout" : "start_error"
426
+ }
427
+ }
428
+
278
429
  /**
279
430
  * cwd 配下の Codex thread 一覧を返す (Cockpit Codex 両対応 セッションピッカー/最新
280
431
  * セッション自動選択用。claude 側 listSessions と対称)。
@@ -317,12 +468,18 @@ export class CodexStreamBridge extends EventEmitter {
317
468
  }
318
469
 
319
470
  _wireClient(session) {
320
- const { client, stream_id } = session
471
+ const { client } = session
321
472
  client.on("notification", (msg) => this._onNotification(session, msg))
322
473
  client.on("request", (msg) => this._onServerRequest(session, msg))
323
474
  client.on("exit", (info) => this._onClientExit(session, info))
324
475
  client.on("error", (err) => {
325
- this.emit("error", { stream_id, thread_id: session.threadId, error: err.message || String(err) })
476
+ // ⚠️ session.stream_id を **都度読む** (in-memory reattach で付け替わるため。wire 時に
477
+ // 分割代入で固定すると reattach 後に旧 stream へ error を emit してしまう)。
478
+ this.emit("error", {
479
+ stream_id: session.stream_id,
480
+ thread_id: session.threadId,
481
+ error: err.message || String(err),
482
+ })
326
483
  })
327
484
  }
328
485
 
package/src/config.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  import { promises as fs, constants as fsConsts, readFileSync } from "node:fs"
11
11
  import path from "node:path"
12
12
  import os from "node:os"
13
+ import { randomUUID } from "node:crypto"
13
14
  import { execFile, execFileSync } from "node:child_process"
14
15
  import { promisify } from "node:util"
15
16
 
@@ -90,6 +91,9 @@ function resolveConfigDir() {
90
91
  return path.join(os.homedir(), ".hub")
91
92
  }
92
93
 
94
+ /** canonical UUID (v4 想定だが版は問わない)。host_key の契約。 */
95
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
96
+
93
97
  export const paths = {
94
98
  get configDir() {
95
99
  return resolveConfigDir()
@@ -97,11 +101,111 @@ export const paths = {
97
101
  get configFile() {
98
102
  return path.join(resolveConfigDir(), "agent.json")
99
103
  },
104
+ get hostKeyFile() {
105
+ return path.join(resolveConfigDir(), "host_key")
106
+ },
100
107
  get pluginsDir() {
101
108
  return path.join(resolveConfigDir(), "plugins")
102
109
  },
103
110
  }
104
111
 
112
+ /**
113
+ * このインストール (物理ホスト) の不変な識別子を返す (無ければ生成して永続化)。
114
+ *
115
+ * ## なぜ必要か (2026-07-17)
116
+ *
117
+ * Hub 側に「このワークスペースで今どの会話 (claude=session_id / codex=thread_id) を
118
+ * 開くか」を保存するにあたり、**その会話がどのホストのファイルシステムに存在するか**
119
+ * を指す必要がある。ところが Hub には物理ホストの安定した ID が無かった:
120
+ *
121
+ * - `CockpitAgent`(agent_id) = **credential のレコード**。再 enroll のたびに revoke /
122
+ * 再作成される (= ホストの同一性を表さない)
123
+ * - `hostname` = **表示用の属性**。Hub の PATCH API で変更でき、OS rename でも変わり、
124
+ * 別の物理端末が同名を名乗れる (= 一意でも不変でもない)
125
+ *
126
+ * そこで install 単位の opaque UUID をここで生成し、enroll 時に Hub へ送る。
127
+ *
128
+ * ## なぜ agent.json ではなく専用ファイルなのか
129
+ *
130
+ * `writeConfig()` は **agent.json を全置換**し、`clearConfig()` は **削除**する。
131
+ * host_key を agent.json に入れると credential のライフサイクルに巻き込まれ、
132
+ * 再 enroll / unenroll で消える = 「credential から独立した ID」という目的を
133
+ * 自ら壊す。別ファイルにして、credential の rotate と完全に分離する。
134
+ *
135
+ * ## 並行安全 (2026-07-17, Codex レビュー指摘)
136
+ *
137
+ * read → ENOENT → write の check-then-create は非原子的で、同じ config dir で 2 つの
138
+ * プロセスが初回起動すると **別々の UUID を生成して互いに上書きし得る** (片方が A を
139
+ * Hub へ送ったのに最終ファイルは B になり、次回起動で B を送る = identity が変わる)。
140
+ * 「install 単位で不変」を生成関数自身が保証できない。
141
+ *
142
+ * ⚠️ 単純な `flag: "wx"` でも不十分だった (2026-07-17、Codex レビュー 6 巡目):
143
+ * wx は **open の排他性**しか保証せず、勝者が open → write → close する間に、
144
+ * EEXIST を受けた敗者が**空 / 書きかけのファイルを読む窓**が残る (その起動だけ
145
+ * host_key の解決に失敗し、ポインタ機能が無効化される)。
146
+ *
147
+ * そこで **temp file へ完成品を書いてから `link()` で公開**する:
148
+ * link(2) は「リンク先が既に存在すれば EEXIST」で原子的なので、**排他性と
149
+ * 「公開されたファイルは常に完成済み」の両方**が同時に満たされる。敗者は勝者の
150
+ * 完成済みファイルを読むだけでよい。
151
+ *
152
+ * @param {{ randomUUIDImpl?: () => string, onBeforePublish?: () => Promise<void> }} [opts]
153
+ * テスト用の注入口 (onBeforePublish は publish 直前に割り込んで競合窓を再現する)
154
+ * @returns {Promise<string>} 既存の値、または新規生成した UUID
155
+ */
156
+ export async function getOrCreateHostKey(opts = {}) {
157
+ const existing = await _readHostKey()
158
+ if (existing) return existing
159
+
160
+ await ensureConfigDir()
161
+ const uuid = (opts.randomUUIDImpl || randomUUID)()
162
+ // 一意な temp 名 (同一 dir。link は同一 FS でしか張れないため)。
163
+ const tmp = `${paths.hostKeyFile}.${process.pid}.${randomUUID()}.tmp`
164
+ try {
165
+ // 0o600 は秘密だからではなく、書き換えられると別ホストになりすませるため。
166
+ await fs.writeFile(tmp, `${uuid}\n`, { mode: 0o600 })
167
+ if (opts.onBeforePublish) await opts.onBeforePublish()
168
+ try {
169
+ // 原子的 publish: 既にあれば EEXIST (= 競合の敗者)。
170
+ await fs.link(tmp, paths.hostKeyFile)
171
+ return uuid
172
+ } catch (err) {
173
+ if (err.code !== "EEXIST") throw err
174
+ // 敗者: 勝者が publish したのは **完成済み**のファイルなので、そのまま読める。
175
+ const winner = await _readHostKey()
176
+ if (!winner) {
177
+ throw new Error("host_key の生成が競合し、勝者の値も読めませんでした")
178
+ }
179
+ return winner
180
+ }
181
+ } finally {
182
+ await fs.unlink(tmp).catch(() => {})
183
+ }
184
+ }
185
+
186
+ /** host_key ファイルを読む。無ければ null。中身が UUID でなければ throw。 */
187
+ async function _readHostKey() {
188
+ let raw
189
+ try {
190
+ raw = await fs.readFile(paths.hostKeyFile, "utf-8")
191
+ } catch (err) {
192
+ if (err.code === "ENOENT") return null
193
+ throw err
194
+ }
195
+ const value = raw.trim()
196
+ if (!value) return null
197
+ if (!UUID_RE.test(value)) {
198
+ // ⚠️ 黙って新しい値へ rotate しない: identity が静かに変わると、Hub 側の
199
+ // 「今どの会話を開くか」が別ホスト扱いで引けなくなり、原因も追えない
200
+ // (Codex レビュー指摘、2026-07-17)。壊れているなら明示的に落とす。
201
+ throw new Error(
202
+ `${paths.hostKeyFile} の内容が UUID ではありません (${value.slice(0, 16)}...)。` +
203
+ "手動で修復するか、ファイルを削除して再生成してください。",
204
+ )
205
+ }
206
+ return value
207
+ }
208
+
105
209
  /** ~/.hub を作成する (chmod 700)。 */
106
210
  export async function ensureConfigDir() {
107
211
  await fs.mkdir(paths.configDir, { recursive: true, mode: 0o700 })
package/src/enroll.mjs CHANGED
@@ -8,7 +8,13 @@
8
8
  */
9
9
  import os from "node:os"
10
10
 
11
- import { detectAvailableClis, detectPlatform, hasConfig, writeConfig } from "./config.mjs"
11
+ import {
12
+ detectAvailableClis,
13
+ detectPlatform,
14
+ getOrCreateHostKey,
15
+ hasConfig,
16
+ writeConfig,
17
+ } from "./config.mjs"
12
18
 
13
19
  const DEFAULT_HUB_URL = process.env.HUB_URL || "https://hub.cocorograph.com"
14
20
 
@@ -35,6 +41,11 @@ export async function enroll(enrollmentToken, opts = {}) {
35
41
  }
36
42
 
37
43
  const availableClis = await detectAvailableClis()
44
+ // このインストール (物理ホスト) の不変な ID。credential (agent_id) と違い、
45
+ // 再 enroll しても同じ値を送り続けるため、Hub 側は「同じホスト」だと分かる。
46
+ // Hub の「今どの会話を開くか」ポインタがこれをキーにする (config.mjs の
47
+ // getOrCreateHostKey の docstring 参照)。
48
+ const hostKey = await getOrCreateHostKey()
38
49
 
39
50
  const res = await fetchImpl(`${hubUrl}/api/cockpit/agents/enroll-complete/`, {
40
51
  method: "POST",
@@ -42,6 +53,7 @@ export async function enroll(enrollmentToken, opts = {}) {
42
53
  body: JSON.stringify({
43
54
  enrollment_token: enrollmentToken,
44
55
  hostname,
56
+ host_key: hostKey,
45
57
  version,
46
58
  platform: detectPlatform(),
47
59
  available_clis: availableClis,
package/src/main.mjs CHANGED
@@ -19,7 +19,13 @@ import path from "node:path"
19
19
 
20
20
  import pino from "pino"
21
21
 
22
- import { detectAvailableClis, detectPlatform, readConfig, writeConfig } from "./config.mjs"
22
+ import {
23
+ detectAvailableClis,
24
+ detectPlatform,
25
+ getOrCreateHostKey,
26
+ readConfig,
27
+ writeConfig,
28
+ } from "./config.mjs"
23
29
  import {
24
30
  codexTerminalSkipVersion,
25
31
  compareBundleVersions,
@@ -775,9 +781,25 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
775
781
  }
776
782
  }
777
783
 
784
+ // 物理ホストの不変 ID。**全起動が通るこの経路で解決**し hello に載せる
785
+ // (enroll 専用にすると既存端末が更新しても Hub に届かない。config.mjs の
786
+ // getOrCreateHostKey docstring 参照)。読めない/壊れている場合は fail-soft:
787
+ // 空文字で続行し、Hub 側はポインタ機能だけを無効化する (agent は通常どおり動く)。
788
+ let hostKey = ""
789
+ try {
790
+ hostKey = await getOrCreateHostKey()
791
+ logger.info({ host_key: hostKey }, "host key resolved")
792
+ } catch (err) {
793
+ logger.warn(
794
+ { err: err.message },
795
+ "host key の解決に失敗 (会話ポインタ機能のみ無効化。agent は通常どおり動作)",
796
+ )
797
+ }
798
+
778
799
  const client = new WsClient(config, {
779
800
  logger,
780
801
  version,
802
+ hostKey,
781
803
  platform: detectPlatform(),
782
804
  availableClis,
783
805
  bundleVersion,
@@ -4175,6 +4197,9 @@ async function dispatch(msg, ctx) {
4175
4197
  approvalPolicy: msg.approval_policy || undefined,
4176
4198
  sandbox: msg.sandbox || undefined,
4177
4199
  resumeThreadId,
4200
+ // Stage4 caller レビュー major #1: attach 試行の安定 id。同じ attach_id で再接続
4201
+ // すると bridge が rebind し、fresh の二重生成を防ぐ (browser の stream_id は都度変わる)。
4202
+ attachId: msg.attach_id || undefined,
4178
4203
  })
4179
4204
  ctx.client.send({
4180
4205
  type: "codex.ready",
@@ -4201,7 +4226,11 @@ async function dispatch(msg, ctx) {
4201
4226
  // - fresh (resume 無し): ゼロ値 + 確定済み threadId で即時 push (症状④)。
4202
4227
  // - resume: codexUsageForThread で実 context% を解決し即時 push (症状③)。
4203
4228
  if (resolvedCwd) {
4204
- if (!resumeThreadId) {
4229
+ // Stage4 caller レビュー major #6: fresh/resume の分岐は **実際の attach 結果**
4230
+ // (info.resuming) で判定する。in-memory reattach は元 msg.resume_thread_id が空
4231
+ // (fresh 再接続) でも既存 thread への復帰 = info.resuming=true なので、resume 側の
4232
+ // 実 context% 解決に載せる (fresh 扱いで context を 0 に戻さない)。
4233
+ if (!info.resuming) {
4205
4234
  clearChatSignal(resolvedCwd, "codex")
4206
4235
  recordChatActivity(resolvedCwd, "codex", {
4207
4236
  status: "waiting",
@@ -4250,7 +4279,14 @@ async function dispatch(msg, ctx) {
4250
4279
  // S3 Codex 堅牢化: request() タイムアウト (codex-appserver-client.mjs) は
4251
4280
  // err.reason='timeout' を付与する。frontend が理由別の文言/表示を出し分け
4252
4281
  // られるよう素通しする (無ければ undefined = キー自体を省略)。
4253
- ...(err.reason ? { reason: err.reason } : {}),
4282
+ // Stage4 caller レビュー major #3: attach 失敗は bridge が operation (resume|start) と
4283
+ // reason (thread_not_found|timeout|resume_error|start_error) を分類する。frontend は
4284
+ // operation=resume かつ reason=thread_not_found のときだけ pointer を修復して既存
4285
+ // thread へ fallback する (I5)。err.attachReason があればそれを優先する。
4286
+ ...(err.attachReason || err.reason
4287
+ ? { reason: err.attachReason || err.reason }
4288
+ : {}),
4289
+ ...(err.attachOperation ? { operation: err.attachOperation } : {}),
4254
4290
  })
4255
4291
  }
4256
4292
  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
 
package/src/ws-client.mjs CHANGED
@@ -114,6 +114,11 @@ export class WsClient extends EventEmitter {
114
114
  this.config = config
115
115
  this.logger = opts.logger
116
116
  this.version = opts.version || "0.1.0"
117
+ // このインストール (物理ホスト) の不変 ID。**enroll だけでなく毎起動の hello で送る**:
118
+ // 既に enroll 済みの端末は更新後も enroll-complete を通らないため、enroll 専用に
119
+ // すると「更新しても host_key が Hub に届かず機能が永久に無効」になる
120
+ // (Codex レビュー指摘、2026-07-17)。backend は hello で空の host_key を backfill する。
121
+ this.hostKey = opts.hostKey || ""
117
122
  this.bundleVersion = opts.bundleVersion || null
118
123
  this.bundleVersionProvider =
119
124
  typeof opts.bundleVersionProvider === "function"
@@ -243,6 +248,7 @@ export class WsClient extends EventEmitter {
243
248
  type: "hello",
244
249
  agent_id: this.config.agent_id,
245
250
  hostname: this.hostname,
251
+ host_key: this.hostKey,
246
252
  version: this.version,
247
253
  bundle_version: this.bundleVersion,
248
254
  platform: this.platform,