@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 +1 -1
- package/src/codex-stream-bridge.mjs +171 -14
- package/src/config.mjs +104 -0
- package/src/enroll.mjs +13 -1
- package/src/main.mjs +39 -3
- package/src/usage.mjs +61 -0
- package/src/ws-client.mjs +6 -0
package/package.json
CHANGED
|
@@ -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
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// rollout
|
|
74
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|