@cocorograph/hub-agent 0.7.104 → 0.8.0
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/claude-history.mjs +0 -113
- package/src/codex-profiles.mjs +43 -0
- package/src/codex-stream-bridge.mjs +42 -37
- package/src/main.mjs +159 -1115
- package/src/profiles.mjs +42 -0
- package/src/tmux.mjs +6 -763
- package/src/usage.mjs +184 -3
- package/src/extract-paragraph.mjs +0 -149
- package/src/jsonl-live-watchers.mjs +0 -283
- package/src/tui-permission-bridge.mjs +0 -280
- package/src/tui-viewer-registry.mjs +0 -201
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -647,116 +647,3 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
|
647
647
|
* @param {{viewingSessionId?: string|null, newestSessionId?: string|null, lastNotifiedNewId?: string|null, lastNotifiedAt?: number|null, now?: number|null, reNotifyMs?: number}} args
|
|
648
648
|
* @returns {{rotated: boolean, newSessionId?: string}}
|
|
649
649
|
*/
|
|
650
|
-
/**
|
|
651
|
-
* 回転候補 jsonl が「bound TUI セッションの本物の /clear 先」か「別アクティビティの jsonl」かを
|
|
652
|
-
* head 4KB だけ読んで判定する純粋ヘルパ (2a 根治)。
|
|
653
|
-
*
|
|
654
|
-
* 背景: 同一 cwd-encoded dir には bound TUI 以外の jsonl が混入し得る:
|
|
655
|
-
* - Task tool subagent (`parent_uuid` / `parentUuid` 付き)
|
|
656
|
-
* - SDK headless `claude -p` (末尾近傍に `type=result`)
|
|
657
|
-
* - 並走 cockpit / 別ターミナル起動の別 TUI (cwd は一致するため弱い signal)
|
|
658
|
-
* - 既存 jsonl が touch されて mtime 最新化 (cwd が viewCwd と不一致なケースは cross-cwd 汚染)
|
|
659
|
-
* これらに対して decideSessionRotation は newest≠viewing で無条件発火するため、ローテに乗ると
|
|
660
|
-
* viewer が別 jsonl へ rotate-bind されて /clear 後の三点リーダー固着・誤生成中表示を起こす。
|
|
661
|
-
*
|
|
662
|
-
* 検出基準 (どれか 1 つでも該当したら reject = 抑止):
|
|
663
|
-
* - `parent_uuid` / `parentUuid` / `parent_session_id` / `parentSessionId` (subagent marker)
|
|
664
|
-
* - `type === 'result'` (SDK headless の完了 marker。先頭 4KB 内に現れたら頭 0KB の極短セッション)
|
|
665
|
-
* - `cwd` フィールドが `viewCwd` と完全不一致 (cross-cwd 汚染)
|
|
666
|
-
*
|
|
667
|
-
* 失敗安全: head 読み失敗 / parse 失敗時は accept (旧挙動 = 即時 rotate へ degrade)。
|
|
668
|
-
* 「/clear が壊れる」リスクは取らないため、不確実時は安全側 (accept) に倒す。
|
|
669
|
-
*
|
|
670
|
-
* @param {{candidatePath?: string|null, viewCwd?: string|null, readImpl?: (p:string,n:number)=>Promise<string|null>, logger?: import('pino').Logger}} args
|
|
671
|
-
* @returns {Promise<{accept: boolean, reason: string}>}
|
|
672
|
-
*/
|
|
673
|
-
export async function validateRotationCandidate({
|
|
674
|
-
candidatePath,
|
|
675
|
-
viewCwd,
|
|
676
|
-
readImpl,
|
|
677
|
-
logger,
|
|
678
|
-
} = {}) {
|
|
679
|
-
if (!candidatePath || !viewCwd) {
|
|
680
|
-
return { accept: true, reason: "missing_args" }
|
|
681
|
-
}
|
|
682
|
-
let text
|
|
683
|
-
try {
|
|
684
|
-
if (typeof readImpl === "function") {
|
|
685
|
-
text = await readImpl(candidatePath, 4096)
|
|
686
|
-
} else {
|
|
687
|
-
const handle = await open(candidatePath, "r")
|
|
688
|
-
try {
|
|
689
|
-
const buf = Buffer.allocUnsafe(4096)
|
|
690
|
-
const { bytesRead } = await handle.read(buf, 0, buf.length, 0)
|
|
691
|
-
text = buf.toString("utf-8", 0, bytesRead)
|
|
692
|
-
} finally {
|
|
693
|
-
await handle.close().catch(() => {})
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
} catch (err) {
|
|
697
|
-
logger?.warn(
|
|
698
|
-
{ err: err?.message, candidatePath },
|
|
699
|
-
"rotation candidate head read failed → accept (degrade to legacy behavior)",
|
|
700
|
-
)
|
|
701
|
-
return { accept: true, reason: `read_failed:${err?.code || "unknown"}` }
|
|
702
|
-
}
|
|
703
|
-
if (!text) return { accept: true, reason: "empty_text" }
|
|
704
|
-
for (const line of text.split("\n")) {
|
|
705
|
-
if (!line) continue
|
|
706
|
-
let obj
|
|
707
|
-
try {
|
|
708
|
-
obj = JSON.parse(line)
|
|
709
|
-
} catch {
|
|
710
|
-
continue
|
|
711
|
-
}
|
|
712
|
-
if (!obj || typeof obj !== "object") continue
|
|
713
|
-
if (
|
|
714
|
-
obj.parent_uuid ||
|
|
715
|
-
obj.parentUuid ||
|
|
716
|
-
obj.parent_session_id ||
|
|
717
|
-
obj.parentSessionId
|
|
718
|
-
) {
|
|
719
|
-
return { accept: false, reason: "subagent_marker" }
|
|
720
|
-
}
|
|
721
|
-
if (obj.type === "result") {
|
|
722
|
-
return { accept: false, reason: "headless_result_marker" }
|
|
723
|
-
}
|
|
724
|
-
if (typeof obj.cwd === "string" && obj.cwd && obj.cwd !== viewCwd) {
|
|
725
|
-
return { accept: false, reason: "cwd_mismatch" }
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
return { accept: true, reason: "passed" }
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
export function decideSessionRotation({
|
|
732
|
-
viewingSessionId,
|
|
733
|
-
newestSessionId,
|
|
734
|
-
lastNotifiedNewId,
|
|
735
|
-
lastNotifiedAt,
|
|
736
|
-
lastNotifiedCount = 0,
|
|
737
|
-
now,
|
|
738
|
-
reNotifyMs = 4000,
|
|
739
|
-
maxReNotify = 5,
|
|
740
|
-
} = {}) {
|
|
741
|
-
if (!newestSessionId || !viewingSessionId) return { rotated: false }
|
|
742
|
-
if (newestSessionId === viewingSessionId) return { rotated: false }
|
|
743
|
-
if (newestSessionId === lastNotifiedNewId) {
|
|
744
|
-
// 同一 new。時刻情報が揃っていて再通知間隔を超えていれば、固着救済のため再通知する。
|
|
745
|
-
// ただし安全弁: 同一 (viewing→newest) ペアを maxReNotify 回再通知してもフロントが
|
|
746
|
-
// 採用しない場合は打ち切る (capped)。フロントが viewing を更新しない=外来 id 汚染や
|
|
747
|
-
// 別 cwd の rotated 誤採用の兆候で、これを無制限に再通知すると rotated 無限ピンポンに
|
|
748
|
-
// なる (perf監査: cockpit↔cocomiru で rotated 244件/h を実測)。
|
|
749
|
-
if (lastNotifiedCount >= maxReNotify) {
|
|
750
|
-
return { rotated: false, capped: true }
|
|
751
|
-
}
|
|
752
|
-
if (
|
|
753
|
-
typeof now === "number" &&
|
|
754
|
-
typeof lastNotifiedAt === "number" &&
|
|
755
|
-
now - lastNotifiedAt >= reNotifyMs
|
|
756
|
-
) {
|
|
757
|
-
return { rotated: true, newSessionId: newestSessionId }
|
|
758
|
-
}
|
|
759
|
-
return { rotated: false }
|
|
760
|
-
}
|
|
761
|
-
return { rotated: true, newSessionId: newestSessionId }
|
|
762
|
-
}
|
package/src/codex-profiles.mjs
CHANGED
|
@@ -181,6 +181,49 @@ export async function getActiveConfigDir() {
|
|
|
181
181
|
return (await getActiveProfile()).configDir
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* profile_id から Codex ホーム (configDir) を解決する (self-describing read 用)。
|
|
186
|
+
*
|
|
187
|
+
* 【なぜ env でなくリクエストの id から引くのか — Read-plane 案C ①】
|
|
188
|
+
* `process.env.CODEX_HOME` はプロファイル切替 (applyActiveCodexEnv) で書き換わる揮発値。
|
|
189
|
+
* read (一覧・履歴) を並行に走らせると、切替の瞬間に in-flight の read が別アカウントの
|
|
190
|
+
* rollout dir を読む競合が起きる。read リクエスト自身が profile_id を搬送し、読む対象を
|
|
191
|
+
* リクエスト時点で固定することで根絶する。profiles.mjs の同名関数と対称。
|
|
192
|
+
*
|
|
193
|
+
* @param {string|null|undefined} profileId 未指定/空/未知 → active へフォールバック
|
|
194
|
+
* @returns {Promise<{configDir:string, profileId:string, resolved:boolean}>}
|
|
195
|
+
*/
|
|
196
|
+
export async function resolveConfigDir(profileId) {
|
|
197
|
+
const reg = await loadRegistry()
|
|
198
|
+
if (profileId) {
|
|
199
|
+
const hit = reg.profiles.find((p) => p.id === profileId)
|
|
200
|
+
if (hit) {
|
|
201
|
+
return { configDir: hit.configDir, profileId: hit.id, resolved: true }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const active =
|
|
205
|
+
reg.profiles.find((p) => p.id === reg.activeId) || makeDefaultCodexProfile()
|
|
206
|
+
return { configDir: active.configDir, profileId: active.id, resolved: false }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* profile_id から rollout の保存ルート (`<configDir>/sessions`) を解決する。
|
|
211
|
+
*
|
|
212
|
+
* ⚠️ `HUB_CODEX_SESSIONS_DIR` (テスト/特殊配置用の明示 override) は usage.mjs の
|
|
213
|
+
* codexSessionsDir() と同じ優先順位で最優先する。override が効かなくなると
|
|
214
|
+
* テストが実ホームを読んでしまうため。
|
|
215
|
+
*
|
|
216
|
+
* @param {string|null|undefined} profileId
|
|
217
|
+
* @returns {Promise<{sessionsDir:string, profileId:string, resolved:boolean}>}
|
|
218
|
+
*/
|
|
219
|
+
export async function resolveSessionsDir(profileId) {
|
|
220
|
+
const r = await resolveConfigDir(profileId)
|
|
221
|
+
const sessionsDir = process.env.HUB_CODEX_SESSIONS_DIR
|
|
222
|
+
? process.env.HUB_CODEX_SESSIONS_DIR
|
|
223
|
+
: path.join(r.configDir, "sessions")
|
|
224
|
+
return { sessionsDir, profileId: r.profileId, resolved: r.resolved }
|
|
225
|
+
}
|
|
226
|
+
|
|
184
227
|
/**
|
|
185
228
|
* active プロファイルを切り替える。存在しない id はエラー。
|
|
186
229
|
* env (CODEX_HOME) の反映と全 Codex セッション再起動は呼び出し側 (main.mjs) の責務。
|
|
@@ -44,7 +44,7 @@ import { EventEmitter } from "node:events"
|
|
|
44
44
|
|
|
45
45
|
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
46
46
|
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
47
|
-
import { codexRolloutPresence } from "./usage.mjs"
|
|
47
|
+
import { codexRolloutPresence, listCodexThreadsFromDisk } from "./usage.mjs"
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
|
|
@@ -306,6 +306,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
306
306
|
deadTerminalTtlMs,
|
|
307
307
|
turnStartTimeoutMs,
|
|
308
308
|
rolloutPresence,
|
|
309
|
+
listThreadsFromDisk,
|
|
309
310
|
historyMaxItems,
|
|
310
311
|
historyMaxBytes,
|
|
311
312
|
} = {}) {
|
|
@@ -326,6 +327,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
326
327
|
this._turnStartTimeoutMs = turnStartTimeoutMs ?? TURN_START_TIMEOUT_MS
|
|
327
328
|
// #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
|
|
328
329
|
this._rolloutPresence = rolloutPresence || codexRolloutPresence
|
|
330
|
+
// Phase 3 (Read-plane スライス2a): 一覧を rollout の disk 走査で作る (app-server 非 spawn)。
|
|
331
|
+
// テストから stub を注入できるよう injectable。
|
|
332
|
+
this._listThreadsFromDisk = listThreadsFromDisk || listCodexThreadsFromDisk
|
|
329
333
|
// T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
|
|
330
334
|
this._historyLimits = {
|
|
331
335
|
maxItems: historyMaxItems ?? HISTORY_MAX_ITEMS,
|
|
@@ -334,6 +338,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
334
338
|
/** @type {Map<string, object>} stream_id -> session record */
|
|
335
339
|
this.sessions = new Map()
|
|
336
340
|
this._accountRateLimitsByProfile = new Map()
|
|
341
|
+
// Phase 3 (Read-plane): listThreads の single-flight。key(`cwd::limit`) -> in-flight Promise。
|
|
342
|
+
// codex.sessions.request を直列キューから外したため、同一 cwd への同時リクエストが
|
|
343
|
+
// app-server の同時 spawn (storm) を起こさないよう 1 本に畳む。
|
|
344
|
+
this._listThreadsInFlight = new Map()
|
|
337
345
|
}
|
|
338
346
|
|
|
339
347
|
/**
|
|
@@ -753,45 +761,42 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
753
761
|
* cwd 配下の Codex thread 一覧を返す (Cockpit Codex 両対応 セッションピッカー/最新
|
|
754
762
|
* セッション自動選択用。claude 側 listSessions と対称)。
|
|
755
763
|
*
|
|
756
|
-
*
|
|
757
|
-
* `
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
764
|
+
* Phase 3 (Read-plane スライス2a, 2026-07-24): **app-server を spawn せず rollout の disk 走査**で
|
|
765
|
+
* 一覧を作る (`listCodexThreadsFromDisk`)。旧実装は使い捨て app-server で `thread/list` を
|
|
766
|
+
* 叩いていたが、完了基準「Codex 一覧で app-server を起動しない」に反し、Read-plane で off-queue
|
|
767
|
+
* 化しても spawn コスト自体が残っていた。一覧に要る 4 フィールド (thread_id/cwd/preview/
|
|
768
|
+
* updated_at_ms) は rollout の header + mtime から安価に取れる。返り値 shape は従来と同一。
|
|
769
|
+
*
|
|
770
|
+
* single-flight (下記 in-flight 畳み込み) は spawn storm 防止用だったが、disk 走査化後は
|
|
771
|
+
* 「同一 (cwd,limit) の同時走査の冗長 I/O 抑止」として維持する (無害・低コスト)。
|
|
761
772
|
*
|
|
762
773
|
* @returns {Promise<Array<{thread_id: string, cwd: string, preview: string, updated_at_ms: number}>>}
|
|
763
774
|
*/
|
|
764
|
-
async listThreads({ cwd, limit } = {}) {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
updated_at_ms:
|
|
790
|
-
(typeof t.updatedAt === "number" ? t.updatedAt : t.recencyAt || 0) * 1000,
|
|
791
|
-
}))
|
|
792
|
-
} finally {
|
|
793
|
-
client.stop()
|
|
794
|
-
}
|
|
775
|
+
async listThreads({ cwd, limit, sessionsDir } = {}) {
|
|
776
|
+
// Phase 3 (Read-plane): single-flight。同一 (読み取り先, cwd, limit) への同時リクエストを
|
|
777
|
+
// 1 本の in-flight Promise に畳む。
|
|
778
|
+
//
|
|
779
|
+
// key の第 1 要素は **実際に読む sessionsDir** にする (Read-plane 案C ①)。
|
|
780
|
+
// 以前は `process.env.CODEX_HOME` を使っていたが、env は切替で書き換わる揮発値なので
|
|
781
|
+
// 「この in-flight がどのアカウントの結果か」を表せず、切替中に別プロファイルの結果を
|
|
782
|
+
// 取り違えうる。解決済みの読み取り先そのものを key にすれば同一性が構造的に保証される。
|
|
783
|
+
// sessionsDir 未指定 (旧経路) のときだけ env 由来の値へフォールバックする。
|
|
784
|
+
const readRoot = sessionsDir || process.env.CODEX_HOME || ""
|
|
785
|
+
const key = `${readRoot}::${cwd || ""}::${limit ?? ""}`
|
|
786
|
+
const existing = this._listThreadsInFlight.get(key)
|
|
787
|
+
if (existing) return existing
|
|
788
|
+
const p = this._doListThreads({ cwd, limit, sessionsDir }).finally(() => {
|
|
789
|
+
this._listThreadsInFlight.delete(key)
|
|
790
|
+
})
|
|
791
|
+
this._listThreadsInFlight.set(key, p)
|
|
792
|
+
return p
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async _doListThreads({ cwd, limit, sessionsDir } = {}) {
|
|
796
|
+
// Phase 3 (Read-plane スライス2a): app-server を spawn せず rollout の disk 走査で一覧を作る。
|
|
797
|
+
// updated_at_ms は rollout ファイルの mtime (= 最終書き込み時刻 = ターン境界に相当) で、
|
|
798
|
+
// claude 側 ClaudeSessionSummary.mtime と単位・意味を揃える。並びは mtime desc。
|
|
799
|
+
return this._listThreadsFromDisk({ cwd, limit, sessionsDir, logger: this.logger })
|
|
795
800
|
}
|
|
796
801
|
|
|
797
802
|
/**
|