@cocorograph/hub-agent 0.7.105 → 0.8.1
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/claude-stream-bridge.mjs +183 -10
- package/src/codex-profiles.mjs +43 -0
- package/src/codex-stream-bridge.mjs +30 -42
- package/src/main.mjs +216 -1120
- 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
|
-
}
|
|
@@ -85,6 +85,42 @@ const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
|
|
|
85
85
|
const CHAT_RESIDENT_RESUME_ENABLED =
|
|
86
86
|
process.env.HUB_AGENT_CHAT_RESIDENT_RESUME !== "0"
|
|
87
87
|
|
|
88
|
+
/** 受理済み input_id リングの上限 (T06603, 2026-07-25)。queue snapshot に載せて
|
|
89
|
+
* browser の「実行完了 vs 未配達」判定に使う。1 セッションの送信待ちは実運用で
|
|
90
|
+
* 数件〜十数件なので 256 あれば「表示中の楽観チップが評価対象から溢れる」ことは無い。
|
|
91
|
+
* 溢れた場合も誤って消すのではなく「未配達 (要再送)」側に倒れるため安全側。 */
|
|
92
|
+
const ACCEPTED_INPUT_ID_RING_MAX = 256
|
|
93
|
+
|
|
94
|
+
/** キュー snapshot の世代 (T06603, 2026-07-25)。
|
|
95
|
+
*
|
|
96
|
+
* 版番号 (seq) は **セッションオブジェクト単位**の連番なので、同一プロセス内でも
|
|
97
|
+
* session が作り直される (resume / 再 attach) と 1 に戻る。一方 backend の写しキャッシュと
|
|
98
|
+
* browser は単調な版を覚えているため、seq だけで比較すると新しい snapshot が永久に
|
|
99
|
+
* 「古い」と判定されて **表示が凍り付く**。そこで seq の上位に世代を置く。
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ **この値が保証するのは「同一プロセス内」の順序だけ** (cross-review round2)。値は
|
|
102
|
+
* 壁時計 ms で、プロセス内では必ず前回より大きい値を返す (同一 ms の衝突回避 + 時計が
|
|
103
|
+
* 巻き戻っても +1 で単調) が、**プロセス再起動をまたぐ順序は保証しない** (NTP のステップ
|
|
104
|
+
* 補正・手動時刻変更・別ホスト間の時計差で逆転しうる)。
|
|
105
|
+
*
|
|
106
|
+
* そしてその保証は **不要** である (cross-review 3 巡目で設計確定)。browser は
|
|
107
|
+
* ① 適用する queue_state を own stream の authoritative コピーだけに限る
|
|
108
|
+
* ② attach (claude.ready) のたびに版番号の基準点を外す
|
|
109
|
+
* という 2 点により、版番号空間を **1 回の attach の中に閉じている**。agent が再起動すれば
|
|
110
|
+
* browser は必ず再 attach するので (agent.status online → reattachSameSession)、
|
|
111
|
+
* プロセスを跨いで版を比較する場面が存在しない。
|
|
112
|
+
*
|
|
113
|
+
* ゆえにここで壁時計以上の保証を作り込もうとしないこと。プロセス間の全順序を求めると
|
|
114
|
+
* 永続採番 (DB sequence) と session 所有権リースが必要になり、表示のために分散合意に近い
|
|
115
|
+
* 機構を抱えることになる (実際に版番号方式では「時計後退で永久 reject」「非権威 producer に
|
|
116
|
+
* よる cache 汚染」の穴が連続して見つかり、設計ごと撤回した)。 */
|
|
117
|
+
let _lastQueueGeneration = 0
|
|
118
|
+
function nextQueueGeneration() {
|
|
119
|
+
const now = Date.now()
|
|
120
|
+
_lastQueueGeneration = now > _lastQueueGeneration ? now : _lastQueueGeneration + 1
|
|
121
|
+
return _lastQueueGeneration
|
|
122
|
+
}
|
|
123
|
+
|
|
88
124
|
/** MCP control 専用 query のアイドル破棄時間 (2026-06-03)。MCP サーバーは Claude Code
|
|
89
125
|
* 設定 (アカウント単位) で全セッション共有のため、チャットセッションとは独立した
|
|
90
126
|
* 「control 専用 query」を 1 本だけ遅延起動して状態取得・再認証・再接続を行う。
|
|
@@ -434,6 +470,24 @@ export class ClaudeStreamSession {
|
|
|
434
470
|
/** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
|
|
435
471
|
* 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
|
|
436
472
|
this._lastEmittedQueueSig = "0:"
|
|
473
|
+
/** キュー snapshot の版番号 (T06603, 2026-07-25)。emit するたびに単調増加する。
|
|
474
|
+
* browser は「受け取った snapshot が手元より新しいときだけ適用する」ことで、
|
|
475
|
+
* ①遅延した relay / backend キャッシュの古い snapshot による巻き戻し
|
|
476
|
+
* ②stream group と session group の二重配送
|
|
477
|
+
* を無害化できる。**欠落の検知には使わない** (snapshot は全状態を含むので、
|
|
478
|
+
* 落ちた版があっても次に届いた版を適用すれば正しくなる)。落ちたまま次が来ない
|
|
479
|
+
* ケースの救済は browser 側の pull (claude.queue.request) が担う。 */
|
|
480
|
+
this._queueStateSeq = 0
|
|
481
|
+
/** この session オブジェクトの世代 (T06603)。上記 nextQueueGeneration の説明を参照。
|
|
482
|
+
* **同一プロセス内の順序のみ**を保証する (プロセス間は backend の接続フェンスが担う)。 */
|
|
483
|
+
this._queueGeneration = nextQueueGeneration()
|
|
484
|
+
/** 配達確認の再表明用 (T06603): この session が受理 (キュー投入 or 直接配送) した
|
|
485
|
+
* input_id の直近リング。`claude.input.ack` は一度きりのユニキャストで、WS 切断が
|
|
486
|
+
* 30s を超えると ws-client の ctrl バッファから破棄され二度と再送されない。その結果
|
|
487
|
+
* browser は「実際には届いて実行されたのに未配達扱い」で送信待ちチップを固着させる。
|
|
488
|
+
* snapshot にこのリングを載せることで、ack を取りこぼした browser も
|
|
489
|
+
* 「キューに無い + 受理済み = 実行完了」と判定でき、pull だけで自己回復できる。 */
|
|
490
|
+
this._acceptedInputIds = []
|
|
437
491
|
|
|
438
492
|
/** 連携カード種別タグ (Phase 2, 2026-07-23): ask-sibling/orchestrate 等の **別セッションからの
|
|
439
493
|
* 注入** を、受け手画面で「青い自分バブル」ではなく専用の連携依頼カードで表示させるため、
|
|
@@ -950,6 +1004,19 @@ export class ClaudeStreamSession {
|
|
|
950
1004
|
* `accepted:false` のときは呼び出し元 (ClaudeStreamBridge.input) が nack を組み立てる。
|
|
951
1005
|
*/
|
|
952
1006
|
async sendMessage(message, opts = {}) {
|
|
1007
|
+
const result = await this._sendMessageInner(message, opts)
|
|
1008
|
+
// 配達確認の再表明 (T06603): 受理 (= ack を返す) の記録を **1 箇所に集約**する。
|
|
1009
|
+
// 投入経路は busy キュー / resident 直行 / fresh start / per-message と 4 通りあり、
|
|
1010
|
+
// 各経路に散らすと新経路追加のたびに記録漏れが起き、browser が「未配達」と誤判定して
|
|
1011
|
+
// チップを固着させる。ack を返す条件 (accepted) と同じ場所で 1 回だけ記録する。
|
|
1012
|
+
const inputId =
|
|
1013
|
+
typeof opts?.input_id === "string" && opts.input_id ? opts.input_id : null
|
|
1014
|
+
if (inputId && result?.accepted) this._recordAcceptedInput(inputId)
|
|
1015
|
+
return result
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** sendMessage 本体 (受理記録は呼び出し元の sendMessage が funnel で行う)。 */
|
|
1019
|
+
async _sendMessageInner(message, opts = {}) {
|
|
953
1020
|
if (this._closed) return { accepted: false, reason: "queue_dead" }
|
|
954
1021
|
const prompt = extractPromptText(message)
|
|
955
1022
|
if (!prompt) return { accepted: false, reason: "empty_message" }
|
|
@@ -1227,7 +1294,7 @@ export class ClaudeStreamSession {
|
|
|
1227
1294
|
return
|
|
1228
1295
|
}
|
|
1229
1296
|
const next = this._pendingMessages.shift()
|
|
1230
|
-
this._emitQueueState([next.text])
|
|
1297
|
+
this._emitQueueState([next.text], { startedIds: [next.id] })
|
|
1231
1298
|
this._runPerMessage(next.text, { ultracode: next.ultracode === true }).catch(
|
|
1232
1299
|
(err) => {
|
|
1233
1300
|
this.logger?.error(
|
|
@@ -1245,10 +1312,37 @@ export class ClaudeStreamSession {
|
|
|
1245
1312
|
// (楽観チップの id と一致させ、確認前キャンセルを可能にする)。無ければ従来の qN。
|
|
1246
1313
|
const id =
|
|
1247
1314
|
typeof inputId === "string" && inputId ? inputId : `q${++this._queueSeq}`
|
|
1315
|
+
// 二重投入ガード (T06603, 2026-07-25): 同一 input_id が既にキューに居るなら積まない。
|
|
1316
|
+
// main.mjs の in-flight dedup (claudeInflightByInputId) で通常は到達しないが、
|
|
1317
|
+
// 「in-flight レジストリから外れた直後 (settle 済み・seen 登録前) の再送」等の窓では
|
|
1318
|
+
// ここが最後の砦になる。id 重複を許すと ①同じ本文が 2 回実行される
|
|
1319
|
+
// ②drain 後も同 id の項目がキューに残り送信待ちチップが消えない、の二重障害になる。
|
|
1320
|
+
if (this._pendingMessages.some((m) => m.id === id)) {
|
|
1321
|
+
this.logger?.info(
|
|
1322
|
+
{ stream_id: this.stream_id, id },
|
|
1323
|
+
"duplicate input_id already queued, skipped",
|
|
1324
|
+
)
|
|
1325
|
+
return id
|
|
1326
|
+
}
|
|
1248
1327
|
this._pendingMessages.push({ id, text: prompt, ultracode: ultracode === true })
|
|
1249
1328
|
return id
|
|
1250
1329
|
}
|
|
1251
1330
|
|
|
1331
|
+
/** 配達確認の再表明 (T06603): 受理済み input_id をリングへ記録する。
|
|
1332
|
+
* snapshot の `accepted` として browser へ載せ、ack 喪失時の自己回復に使う。 */
|
|
1333
|
+
_recordAcceptedInput(inputId) {
|
|
1334
|
+
if (typeof inputId !== "string" || !inputId) return
|
|
1335
|
+
const at = this._acceptedInputIds.indexOf(inputId)
|
|
1336
|
+
if (at !== -1) this._acceptedInputIds.splice(at, 1)
|
|
1337
|
+
this._acceptedInputIds.push(inputId)
|
|
1338
|
+
if (this._acceptedInputIds.length > ACCEPTED_INPUT_ID_RING_MAX) {
|
|
1339
|
+
this._acceptedInputIds.splice(
|
|
1340
|
+
0,
|
|
1341
|
+
this._acceptedInputIds.length - ACCEPTED_INPUT_ID_RING_MAX,
|
|
1342
|
+
)
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1252
1346
|
/** キャンセル機能 (0.6.26): browser から id 指定で送信待ちメッセージを 1 件取り消す。
|
|
1253
1347
|
* 実行開始済み (既に drain された) メッセージは pending に残っていないので no-op になる。
|
|
1254
1348
|
* 削除に成功したら queue_state を再 emit して送信待ちチップを更新する。 */
|
|
@@ -1269,9 +1363,30 @@ export class ClaudeStreamSession {
|
|
|
1269
1363
|
{ stream_id: this.stream_id, id, queued: this._pendingMessages.length },
|
|
1270
1364
|
"queued message canceled",
|
|
1271
1365
|
)
|
|
1272
|
-
this._emitQueueState()
|
|
1273
1366
|
}
|
|
1274
|
-
|
|
1367
|
+
// T06603 cross-review 5 巡目: **「消えた」だけでは取消成功と実行開始済みを区別できない**。
|
|
1368
|
+
// drain の started フレームが落ちていると、browser は「キューに無い + 受理済み」で
|
|
1369
|
+
// 実行完了と判定してチップを消すが、ユーザーから見ると「取り消せた」ようにしか見えず、
|
|
1370
|
+
// しかも started 欠落で発言バブルも出ていない。ここで結末を明示する。
|
|
1371
|
+
// - pending から除去できた → cancelled
|
|
1372
|
+
// - pending に無い + 受理済み → already_started (もう走っている)
|
|
1373
|
+
// - pending に無い + 未受理 → cancelled (先着キャンセルガードを張ったので、
|
|
1374
|
+
// 後から届く同 id の input は破棄される)
|
|
1375
|
+
const outcome = removed
|
|
1376
|
+
? "cancelled"
|
|
1377
|
+
: this._acceptedInputIds.includes(id)
|
|
1378
|
+
? "already_started"
|
|
1379
|
+
: "cancelled"
|
|
1380
|
+
// ⚠️ T06603 cross-review 6 巡目: **snapshot をここで emit しない**。
|
|
1381
|
+
// 従来はここで force 再表明していたが、そのフレームは outcome 応答より先に browser へ
|
|
1382
|
+
// 届く (backend は agent の到着順に relay する)。先に届いた空 snapshot が「キューに無い
|
|
1383
|
+
// + 受理済み = 実行完了」の判定でチップを消してしまい、後続の outcome は対象不在で
|
|
1384
|
+
// no-op になる → already_started のバブル補償が発火しない。
|
|
1385
|
+
// 呼び出し元が outcome を送ってから flush() を呼ぶことで、配送順を確定させる。
|
|
1386
|
+
return {
|
|
1387
|
+
outcome,
|
|
1388
|
+
flush: () => this._emitQueueState([], { force: true }),
|
|
1389
|
+
}
|
|
1275
1390
|
}
|
|
1276
1391
|
|
|
1277
1392
|
/** 改修3: pending キューの現状を browser へ通知する (送信待ちチップ表示用)。
|
|
@@ -1285,6 +1400,12 @@ export class ClaudeStreamSession {
|
|
|
1285
1400
|
* 署名が前回と同一でも browser がライブ配信を取りこぼしている可能性があるため。 */
|
|
1286
1401
|
_emitQueueState(started = [], opts = undefined) {
|
|
1287
1402
|
const force = opts?.force === true
|
|
1403
|
+
// 実行開始した項目の input_id (started と parallel。T06603 cross-review major#2)。
|
|
1404
|
+
// 本文テキストだけだと、同一本文を連投したときに 2 通目のユーザー発言バブルが
|
|
1405
|
+
// 「既に同じ本文のバブルがある」と誤判定されて永久に出ない。id で突合させる。
|
|
1406
|
+
const startedIds = Array.isArray(opts?.startedIds)
|
|
1407
|
+
? opts.startedIds
|
|
1408
|
+
: started.map(() => null)
|
|
1288
1409
|
const count = this._pendingMessages.length
|
|
1289
1410
|
// 署名 = 件数 + id 列。件数が同じでもキャンセルで中身が変われば通知する。
|
|
1290
1411
|
const sig = `${count}:${this._pendingMessages.map((m) => m.id).join(",")}`
|
|
@@ -1311,19 +1432,47 @@ export class ClaudeStreamSession {
|
|
|
1311
1432
|
// 昇格させるため、ここで切り詰めると本文が欠ける。チップの省略表示は CSS 側で行う。
|
|
1312
1433
|
// items は id 付きでキャンセルボタンの対象特定に使う (0.6.26)。
|
|
1313
1434
|
this.onEvent?.({
|
|
1314
|
-
|
|
1315
|
-
pending: count,
|
|
1316
|
-
messages: this._pendingMessages.map((m) => m.text),
|
|
1317
|
-
items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
|
|
1435
|
+
...this._buildQueueSnapshot(),
|
|
1318
1436
|
started,
|
|
1319
1437
|
// started と parallel。連携注入なら 'peer'|'orchestrator'、通常は null。
|
|
1320
1438
|
startedSources,
|
|
1439
|
+
// started と parallel。キュー由来なら input_id、注入等で id が無ければ null。
|
|
1440
|
+
startedIds,
|
|
1321
1441
|
})
|
|
1322
1442
|
} catch {
|
|
1323
1443
|
/* ignore */
|
|
1324
1444
|
}
|
|
1325
1445
|
}
|
|
1326
1446
|
|
|
1447
|
+
/** キュー snapshot (T06603, 2026-07-25): 現在の権威キュー状態を 1 個のオブジェクトに
|
|
1448
|
+
* まとめる。ライブ配信 (_emitQueueState) と pull 応答 (claude.queue.request) の
|
|
1449
|
+
* **両方がこの 1 関数を通る**ため、経路によって形が食い違うことがない。
|
|
1450
|
+
* 版番号 (seq) はここで採番する = 「browser へ出す状態が 1 つ進んだ」の唯一の定義。 */
|
|
1451
|
+
_buildQueueSnapshot() {
|
|
1452
|
+
this._queueStateSeq += 1
|
|
1453
|
+
return {
|
|
1454
|
+
type: "queue_state",
|
|
1455
|
+
// 世代 (T06603 cross-review blocker): 再起動 / resume で seq が 1 に戻っても、
|
|
1456
|
+
// 受け手が「古い」と誤判定しないよう上位キーとして必ず載せる。
|
|
1457
|
+
generation: this._queueGeneration,
|
|
1458
|
+
seq: this._queueStateSeq,
|
|
1459
|
+
pending: this._pendingMessages.length,
|
|
1460
|
+
messages: this._pendingMessages.map((m) => m.text),
|
|
1461
|
+
items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
|
|
1462
|
+
// 受理済み input_id (直近リング)。browser は「キューに無い + ここに有る = 実行完了」
|
|
1463
|
+
// と判定して楽観チップを消し、「キューに無い + ここにも無い = 未配達」なら
|
|
1464
|
+
// 消さずに再送導線を出す (無言削除の回帰を作らないための 2 軸判定)。
|
|
1465
|
+
accepted: [...this._acceptedInputIds],
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** pull 型再同期 (T06603): 現在のキュー snapshot を返す (emit はしない)。
|
|
1470
|
+
* browser の claude.queue.request に対して claude.queue.response で返す。
|
|
1471
|
+
* 純粋な読み取りなので Read-plane (並行レーン) から呼んでよい。 */
|
|
1472
|
+
queueSnapshot() {
|
|
1473
|
+
return this._buildQueueSnapshot()
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1327
1476
|
/** 連携カード種別 (Phase 2, cross-review major#1): **idle 直接送信経路**用。busy→queue→drain は
|
|
1328
1477
|
* _emitQueueState([text]) を通るが、idle 直接実行 (resident fresh start / per-message 即時) は
|
|
1329
1478
|
* started を出さないため、注入の source が startedSources に載らずカード化されない。ここで
|
|
@@ -1527,7 +1676,11 @@ export class ClaudeStreamSession {
|
|
|
1527
1676
|
// codex-review minor: session が close 中 (outcome==="closed") は next を配送していない。
|
|
1528
1677
|
// started 通知 (queue_state に [next.text] を載せる) を出すと未配送メッセージが「開始済み」に
|
|
1529
1678
|
// 見えるため抑止する。closed は終端状態なので pending へ戻さず破棄でよい (session 撤去中)。
|
|
1530
|
-
|
|
1679
|
+
// T06603 (2026-07-25): ただし破棄したこと自体は通知する (started 無しの force 再表明)。
|
|
1680
|
+
// 従来は shift 済みなのに emit しないため、当該項目のチップが browser に残り続けていた。
|
|
1681
|
+
if (outcome !== "closed")
|
|
1682
|
+
this._emitQueueState([next.text], { startedIds: [next.id] })
|
|
1683
|
+
else this._emitQueueState([], { force: true })
|
|
1531
1684
|
} catch (err) {
|
|
1532
1685
|
this._busy = false
|
|
1533
1686
|
this._pendingMessages.unshift(next)
|
|
@@ -1709,7 +1862,7 @@ export class ClaudeStreamSession {
|
|
|
1709
1862
|
toSDKUserMessage(next.text),
|
|
1710
1863
|
next.ultracode === true,
|
|
1711
1864
|
)
|
|
1712
|
-
this._emitQueueState([next.text])
|
|
1865
|
+
this._emitQueueState([next.text], { startedIds: [next.id] })
|
|
1713
1866
|
}
|
|
1714
1867
|
// マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
|
|
1715
1868
|
// closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
|
|
@@ -1868,7 +2021,12 @@ export class ClaudeStreamSession {
|
|
|
1868
2021
|
close() {
|
|
1869
2022
|
this._closed = true
|
|
1870
2023
|
// 改修3: 未処理の pending を破棄 (セッション終了後に drain しないため)。
|
|
2024
|
+
const hadPending = this._pendingMessages.length > 0
|
|
1871
2025
|
this._pendingMessages = []
|
|
2026
|
+
// T06603 (2026-07-25): 破棄したことを必ず通知する。従来は無言で捨てていたため、
|
|
2027
|
+
// 「セッションが畳まれた後もチップだけが残る」表示が残留していた。破棄は状態変化なので
|
|
2028
|
+
// force で貫通させる (started=[] なのでバブル昇格は起きない)。
|
|
2029
|
+
if (hadPending) this._emitQueueState([], { force: true })
|
|
1872
2030
|
// 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
|
|
1873
2031
|
if (this._inputQueue) this._inputQueue.close()
|
|
1874
2032
|
this.abortTurn()
|
|
@@ -2932,11 +3090,26 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2932
3090
|
const s = this.sessions.get(stream_id)
|
|
2933
3091
|
if (!s) {
|
|
2934
3092
|
this.logger?.warn({ stream_id, id }, "claude.queue.cancel but stream missing")
|
|
2935
|
-
|
|
3093
|
+
// T06603 cross-review 4/5 巡目: **結末を必ず browser へ返す**。browser はキャンセル
|
|
3094
|
+
// 要求時にチップを消さず「取消中」の印を付けて権威の確認を待つ設計なので、
|
|
3095
|
+
// 無応答だと印が外れる契機が無く「取消中」のまま固着する。
|
|
3096
|
+
return { outcome: "stream_missing", flush: () => {} }
|
|
2936
3097
|
}
|
|
2937
3098
|
return s.cancelQueued(id)
|
|
2938
3099
|
}
|
|
2939
3100
|
|
|
3101
|
+
/** pull 型再同期 (T06603, 2026-07-25): browser の claude.queue.request に答えるため、
|
|
3102
|
+
* 現在の権威キュー snapshot を返す。stream が無ければ null (browser 側は
|
|
3103
|
+
* 「まだ attach していない」として扱い、ready 後に引き直す)。 */
|
|
3104
|
+
queueSnapshot({ stream_id }) {
|
|
3105
|
+
const s = this.sessions.get(stream_id)
|
|
3106
|
+
if (!s) {
|
|
3107
|
+
this.logger?.warn({ stream_id }, "claude.queue.request but stream missing")
|
|
3108
|
+
return null
|
|
3109
|
+
}
|
|
3110
|
+
return { session_id: s.sessionId || null, snapshot: s.queueSnapshot() }
|
|
3111
|
+
}
|
|
3112
|
+
|
|
2940
3113
|
/**
|
|
2941
3114
|
* セッション停止 (graceful)。実行中ターンは中断せず完走させ、完走後に
|
|
2942
3115
|
* onReap で Map から撤去する。アイドルなら即時撤去。
|
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,
|
|
@@ -757,58 +761,42 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
757
761
|
* cwd 配下の Codex thread 一覧を返す (Cockpit Codex 両対応 セッションピッカー/最新
|
|
758
762
|
* セッション自動選択用。claude 側 listSessions と対称)。
|
|
759
763
|
*
|
|
760
|
-
*
|
|
761
|
-
* `
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
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 抑止」として維持する (無害・低コスト)。
|
|
765
772
|
*
|
|
766
773
|
* @returns {Promise<Array<{thread_id: string, cwd: string, preview: string, updated_at_ms: number}>>}
|
|
767
774
|
*/
|
|
768
|
-
async listThreads({ cwd, limit } = {}) {
|
|
769
|
-
// Phase 3 (Read-plane): single-flight。同一 (cwd, limit) への同時リクエストを
|
|
770
|
-
// in-flight Promise
|
|
771
|
-
|
|
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 ?? ""}`
|
|
772
786
|
const existing = this._listThreadsInFlight.get(key)
|
|
773
787
|
if (existing) return existing
|
|
774
|
-
const p = this._doListThreads({ cwd, limit }).finally(() => {
|
|
788
|
+
const p = this._doListThreads({ cwd, limit, sessionsDir }).finally(() => {
|
|
775
789
|
this._listThreadsInFlight.delete(key)
|
|
776
790
|
})
|
|
777
791
|
this._listThreadsInFlight.set(key, p)
|
|
778
792
|
return p
|
|
779
793
|
}
|
|
780
794
|
|
|
781
|
-
async _doListThreads({ cwd, limit } = {}) {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const result = await client.request("thread/list", {
|
|
787
|
-
cwd: cwd || undefined,
|
|
788
|
-
limit: limit || undefined,
|
|
789
|
-
// ⚠️ updated_at を使う (recency_at ではない)。recency_at は「読み込み(=スレッドを
|
|
790
|
-
// 開くだけ)も含めた使用感」の並び順なので、古いスレッドを開いただけでサイドバー最上位
|
|
791
|
-
// に浮上してしまう (症状⑨=実害)。サイドバーは「会話への書き込み(=ターン境界)」で
|
|
792
|
-
// 並べたいので、最終書き込み時刻の updated_at を採用する。
|
|
793
|
-
// (Claude 側 listSessions も jsonl 内の最終メッセージ timestamp = ターン境界に揃えた)
|
|
794
|
-
sortKey: "updated_at",
|
|
795
|
-
sortDirection: "desc",
|
|
796
|
-
})
|
|
797
|
-
const threads = Array.isArray(result?.data) ? result.data : []
|
|
798
|
-
return threads.map((t) => ({
|
|
799
|
-
thread_id: t.id,
|
|
800
|
-
cwd: t.cwd || "",
|
|
801
|
-
preview: typeof t.preview === "string" ? t.preview.slice(0, 80) : "",
|
|
802
|
-
// updatedAt 優先 (無ければ recencyAt fallback) を ms へ変換。claude 側
|
|
803
|
-
// ClaudeSessionSummary.mtime (ms epoch) と同じ単位に揃え、frontend が同じ並び替え
|
|
804
|
-
// ロジックを使い回せるようにする。並び順の source of truth は上の sortKey なので、
|
|
805
|
-
// ここは表示用の mtime も updatedAt を優先させて sortKey と一貫させる。
|
|
806
|
-
updated_at_ms:
|
|
807
|
-
(typeof t.updatedAt === "number" ? t.updatedAt : t.recencyAt || 0) * 1000,
|
|
808
|
-
}))
|
|
809
|
-
} finally {
|
|
810
|
-
client.stop()
|
|
811
|
-
}
|
|
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 })
|
|
812
800
|
}
|
|
813
801
|
|
|
814
802
|
/**
|