@cocorograph/hub-agent 0.7.74 → 0.7.76
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 +205 -3
- package/src/claude-stream-bridge.mjs +111 -9
- package/src/codex-stream-bridge.mjs +85 -7
- package/src/sibling-inject.mjs +107 -14
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -73,6 +73,68 @@ export function clearHydrateCacheForTest() {
|
|
|
73
73
|
hydrateCache.clear()
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* listSessions の並び順キー last_turn_at_ms を **cwd(dir) 単位** でキャッシュする。
|
|
78
|
+
*
|
|
79
|
+
* 背景 (finding 3): listSessions は Cockpit の閲覧ハートビート経路 (main.mjs) からも
|
|
80
|
+
* limit:1 で周期的に呼ばれる。毎回 cwd 配下の全 jsonl を stat→open→末尾 read すると、
|
|
81
|
+
* セッション数 N に比例した定期 I/O (最悪 64KB×N) が閲覧者ごとに走る。ほとんどのファイルは
|
|
82
|
+
* 会話が進んでいない (mtime/size 不変) ので、キャッシュヒット時はディスク読みを丸ごと省く。
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ round2 finding: 単一グローバル LRU (上限 N 件) だと、1 dir のファイル数がその上限を
|
|
85
|
+
* 超えたとき「毎回同じ順で全件走査 → 先頭 miss が末尾側の既存エントリを順次追い出す」
|
|
86
|
+
* スキャン汚染で結局ほぼ全 miss になる (実環境に 1 dir 1996 件の履歴あり)。これを避けるため
|
|
87
|
+
* キャッシュを **dir → (filePath→{mtimeMs,size,lastTurnAtMs}) の 2 段**にし、LRU 退避は
|
|
88
|
+
* **dir 単位**でのみ行う。1 dir 内の全ファイルは同時に保持されるので、ファイル総数が
|
|
89
|
+
* どれだけ多くても 1 回の走査でキャッシュが自壊しない。dir 数を上限 (同時に見る cwd 数) で
|
|
90
|
+
* 縛るのでメモリも有界。dir 内は毎回の走査で「今存在するファイル」に prune する。
|
|
91
|
+
*/
|
|
92
|
+
const LAST_TURN_DIR_CACHE_MAX = 64 // 同時にキャッシュを保持する cwd(dir) 数の上限
|
|
93
|
+
const lastTurnDirCache = new Map() // dir -> Map<filePath, {mtimeMs, size, lastTurnAtMs}>
|
|
94
|
+
|
|
95
|
+
// テスト用: extractLastTurnAtMs の実 I/O 呼び出し回数 (キャッシュ有効性の回帰テスト用)。
|
|
96
|
+
let extractCallCountForTest = 0
|
|
97
|
+
export function getExtractCallCountForTest() {
|
|
98
|
+
return extractCallCountForTest
|
|
99
|
+
}
|
|
100
|
+
export function resetExtractCallCountForTest() {
|
|
101
|
+
extractCallCountForTest = 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getLastTurnDirMap(dir) {
|
|
105
|
+
let fileMap = lastTurnDirCache.get(dir)
|
|
106
|
+
if (fileMap) {
|
|
107
|
+
// LRU touch (再挿入で挿入順を更新)。
|
|
108
|
+
lastTurnDirCache.delete(dir)
|
|
109
|
+
lastTurnDirCache.set(dir, fileMap)
|
|
110
|
+
return fileMap
|
|
111
|
+
}
|
|
112
|
+
fileMap = new Map()
|
|
113
|
+
lastTurnDirCache.set(dir, fileMap)
|
|
114
|
+
while (lastTurnDirCache.size > LAST_TURN_DIR_CACHE_MAX) {
|
|
115
|
+
const oldest = lastTurnDirCache.keys().next().value
|
|
116
|
+
lastTurnDirCache.delete(oldest)
|
|
117
|
+
}
|
|
118
|
+
return fileMap
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function getLastTurnAtMsCached(fileMap, filePath, mtimeMs, size) {
|
|
122
|
+
const entry = fileMap.get(filePath)
|
|
123
|
+
if (entry && entry.mtimeMs === mtimeMs && entry.size === size) return entry.lastTurnAtMs
|
|
124
|
+
// extract に fallback を渡さず null を受ける。巨大最終行 (>4MB) 等で抽出不能のとき、
|
|
125
|
+
// mtime へ飛ばして再浮上させるのではなく **前回の last_turn_at_ms を維持** する (sticky。
|
|
126
|
+
// round2 minor 対応)。前回値も無ければ最後の手段として mtime。
|
|
127
|
+
const extracted = await extractLastTurnAtMs(filePath, null)
|
|
128
|
+
const lastTurnAtMs = extracted ?? entry?.lastTurnAtMs ?? mtimeMs
|
|
129
|
+
fileMap.set(filePath, { mtimeMs, size, lastTurnAtMs })
|
|
130
|
+
return lastTurnAtMs
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** テスト用: last_turn_at_ms キャッシュを全消去する。 */
|
|
134
|
+
export function clearLastTurnCacheForTest() {
|
|
135
|
+
lastTurnDirCache.clear()
|
|
136
|
+
}
|
|
137
|
+
|
|
76
138
|
/**
|
|
77
139
|
* jsonl の生 object を、Browser が SDK message として受け取れる表示用 shape に正規化する。
|
|
78
140
|
* 余分な内部メタは落とし、表示・集計に必要なフィールドだけを superset で拾う。
|
|
@@ -318,6 +380,124 @@ async function extractPreview(filePath) {
|
|
|
318
380
|
return ""
|
|
319
381
|
}
|
|
320
382
|
|
|
383
|
+
/**
|
|
384
|
+
* jsonl ファイルの「最後の会話ターン境界の時刻」(= 末尾メッセージに埋め込まれた timestamp) を
|
|
385
|
+
* ms epoch で返す。無ければ null。
|
|
386
|
+
*
|
|
387
|
+
* 背景 (症状⑨=実害): サイドバーの並び順はファイル mtime 降順だった (listSessions)。だが
|
|
388
|
+
* mtime は「会話への書き込み」以外の操作 (resume 時の jsonl 書き戻し・compact/summary の
|
|
389
|
+
* 全書き換え・ツールによる touch 等) でも bump するため、古いセッションを開いただけで最上位に
|
|
390
|
+
* 浮上する等の並び順ズレを起こす。会話の各行には ISO8601 の `timestamp` が埋め込まれており、
|
|
391
|
+
* これは resume 書き戻しでも歴史値として保存される (= 実際のターン発生時刻)。末尾行の
|
|
392
|
+
* timestamp を採れば「最後にターンが動いた時刻」で安定して並べられる。
|
|
393
|
+
*
|
|
394
|
+
* 末尾だけを読むため、ファイルサイズに関係なく最後の数十 KB だけを fd で読む
|
|
395
|
+
* (Claude jsonl は append-then-close なので末尾行は必ず完結している)。
|
|
396
|
+
*
|
|
397
|
+
* ⚠️ 2 つの落とし穴を回避している:
|
|
398
|
+
* 1. **巨大な最終行** (tool_result / 画像 base64 埋め込み等で 1 メッセージが 64KB 超も普通)。
|
|
399
|
+
* 固定窓だと最終行が窓境界で切れて JSON.parse できず取りこぼす。→ user/assistant の
|
|
400
|
+
* message 行を拾えるまで窓を倍々に読み広げる (上限 MAX_READ)。
|
|
401
|
+
* 2. **非ターン行の timestamp** (message 後の `attachment` / `system` の stop_hook_summary /
|
|
402
|
+
* turn_duration 等にも timestamp があり、会話ターン境界でない操作で時刻が進む)。
|
|
403
|
+
* → 採用対象を `type in {user, assistant}` かつ `message` を持つ行に限定する。
|
|
404
|
+
*
|
|
405
|
+
* @param {string} filePath
|
|
406
|
+
* @param {number} fallbackMtimeMs stat 済みなら渡す (末尾に会話 message が無い時の保険)
|
|
407
|
+
* @returns {Promise<number|null>}
|
|
408
|
+
*/
|
|
409
|
+
const LAST_TURN_TAIL_CHUNK = 65536
|
|
410
|
+
const LAST_TURN_MAX_READ = 4 * 1024 * 1024 // 4MB を超えて遡っても割に合わない → fallback
|
|
411
|
+
|
|
412
|
+
/** テキスト中の「最後の user/assistant message 行」の timestamp(ms) を返す。無ければ null。 */
|
|
413
|
+
function lastTurnTsFromText(text) {
|
|
414
|
+
const lines = text.split("\n")
|
|
415
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
416
|
+
const line = lines[i]
|
|
417
|
+
if (!line) continue
|
|
418
|
+
let obj
|
|
419
|
+
try {
|
|
420
|
+
obj = JSON.parse(line)
|
|
421
|
+
} catch {
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
// 会話ターン境界 = user/assistant の message 行のみ (finding 2)。
|
|
425
|
+
if ((obj?.type === "user" || obj?.type === "assistant") && obj.message) {
|
|
426
|
+
const ts = obj.timestamp
|
|
427
|
+
if (typeof ts === "string") {
|
|
428
|
+
const ms = Date.parse(ts)
|
|
429
|
+
if (!Number.isNaN(ms)) return ms
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return null
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 巨大行 (読み込み上限 4MB を超え、完全な行として parse できない) で正規 parse が空振り
|
|
438
|
+
* したときの最終手段: tail テキストから最後の `"timestamp":"<ISO>"` を正規表現で拾う
|
|
439
|
+
* (round2 minor / round3 対応)。
|
|
440
|
+
*
|
|
441
|
+
* Claude jsonl の各行はトップレベルに `"timestamp"` を持ち、これは message 本文の**後ろ**に
|
|
442
|
+
* 来る (= 行末側)。よって「最後の一致」を採ればトップレベルの timestamp をほぼ確実に拾える
|
|
443
|
+
* (message 本文内に稀に "timestamp" があっても、それより後ろのトップレベル側が勝つ)。
|
|
444
|
+
* 完全 parse で type を検証できない degraded path 専用。mtime 再浮上 (症状⑨) を避けるための
|
|
445
|
+
* 保守的 fallback として、この不確実性を許容する。
|
|
446
|
+
*/
|
|
447
|
+
const TIMESTAMP_FIELD_RE = /"timestamp":"([^"]+)"/g
|
|
448
|
+
function regexLastTimestampMs(text) {
|
|
449
|
+
let m
|
|
450
|
+
let last = null
|
|
451
|
+
TIMESTAMP_FIELD_RE.lastIndex = 0
|
|
452
|
+
while ((m = TIMESTAMP_FIELD_RE.exec(text)) !== null) last = m[1]
|
|
453
|
+
if (last == null) return null
|
|
454
|
+
const ms = Date.parse(last)
|
|
455
|
+
return Number.isNaN(ms) ? null : ms
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function extractLastTurnAtMs(filePath, fallbackMtimeMs = null) {
|
|
459
|
+
extractCallCountForTest++
|
|
460
|
+
let handle
|
|
461
|
+
try {
|
|
462
|
+
handle = await open(filePath, "r")
|
|
463
|
+
const st = await handle.stat()
|
|
464
|
+
if (st.size <= 0) return fallbackMtimeMs
|
|
465
|
+
let readLen = Math.min(LAST_TURN_TAIL_CHUNK, st.size)
|
|
466
|
+
let hugeLineText = null // 上限まで読んでも完全行を parse できなかった巨大行の raw tail
|
|
467
|
+
for (;;) {
|
|
468
|
+
const start = st.size - readLen
|
|
469
|
+
const buf = Buffer.allocUnsafe(readLen)
|
|
470
|
+
const { bytesRead } = await handle.read(buf, 0, readLen, start)
|
|
471
|
+
const text = buf.toString("utf-8", 0, bytesRead)
|
|
472
|
+
// 窓の先頭が partial の可能性: start>0 なら最初の改行より前 (途中から始まる行) を捨て、
|
|
473
|
+
// 完全な行だけを対象にする。改行が無い (= 1 行が窓より大きい) 場合は下で窓拡張。
|
|
474
|
+
const nl = text.indexOf("\n")
|
|
475
|
+
const usable = start > 0 ? (nl >= 0 ? text.slice(nl + 1) : "") : text
|
|
476
|
+
const ms = lastTurnTsFromText(usable)
|
|
477
|
+
if (ms != null) return ms
|
|
478
|
+
// 見つからず、まだファイル先頭に届いておらず上限内なら窓を倍にして読み足す
|
|
479
|
+
// (巨大な最終行を完全に取り込むまで遡る。finding 1)。
|
|
480
|
+
if (start <= 0 || readLen >= LAST_TURN_MAX_READ) {
|
|
481
|
+
// 上限まで読んでも完全行が取れなかった (start>0 のまま budget 到達) = 巨大最終行。
|
|
482
|
+
// cold path (キャッシュ prev 値も無い) で mtime へ飛んで再浮上しないよう、
|
|
483
|
+
// raw tail から timestamp を正規表現で拾う degraded fallback を試みる。
|
|
484
|
+
if (start > 0 && readLen >= LAST_TURN_MAX_READ) hugeLineText = text
|
|
485
|
+
break
|
|
486
|
+
}
|
|
487
|
+
readLen = Math.min(readLen * 2, st.size, LAST_TURN_MAX_READ)
|
|
488
|
+
}
|
|
489
|
+
if (hugeLineText != null) {
|
|
490
|
+
const ms = regexLastTimestampMs(hugeLineText)
|
|
491
|
+
if (ms != null) return ms
|
|
492
|
+
}
|
|
493
|
+
return fallbackMtimeMs
|
|
494
|
+
} catch {
|
|
495
|
+
return fallbackMtimeMs
|
|
496
|
+
} finally {
|
|
497
|
+
await handle?.close()
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
321
501
|
/**
|
|
322
502
|
* cwd 配下の全 jsonl ファイルから session 一覧を返す。
|
|
323
503
|
* 各ファイルから session_id / 最終更新時刻 / サイズ / 最初の user message preview を抽出。
|
|
@@ -341,6 +521,9 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
|
341
521
|
logger?.warn({ err: err.message, dir }, "claude history list failed")
|
|
342
522
|
return { sessions: [], error: err.message }
|
|
343
523
|
}
|
|
524
|
+
// この dir 専用の last_turn_at_ms キャッシュ (dir 単位 LRU。スキャン汚染しない。finding 3/round2)。
|
|
525
|
+
const fileMap = getLastTurnDirMap(dir)
|
|
526
|
+
const seenPaths = new Set()
|
|
344
527
|
const stats = []
|
|
345
528
|
for (const f of files) {
|
|
346
529
|
if (!f.endsWith(".jsonl")) continue
|
|
@@ -348,13 +531,32 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
|
348
531
|
const filePath = path.join(dir, f)
|
|
349
532
|
try {
|
|
350
533
|
const st = await stat(filePath)
|
|
351
|
-
|
|
534
|
+
seenPaths.add(filePath)
|
|
535
|
+
// last_turn_at_ms = jsonl 末尾メッセージの埋め込み timestamp (会話ターン境界)。
|
|
536
|
+
// サイドバーの並び順はこれを使う (mtime は resume 書き戻し等でも bump し、古い
|
|
537
|
+
// セッションを開いただけで浮上する症状⑨の原因になるため)。抽出不能なら前回値 sticky。
|
|
538
|
+
// (mtimeMs+size キャッシュ経由。ハートビート経路の周期呼び出しで全ファイルを毎回
|
|
539
|
+
// 読み直さない。finding 3)
|
|
540
|
+
const last_turn_at_ms = await getLastTurnAtMsCached(fileMap, filePath, st.mtimeMs, st.size)
|
|
541
|
+
stats.push({
|
|
542
|
+
session_id,
|
|
543
|
+
file_path: filePath,
|
|
544
|
+
mtime: st.mtimeMs,
|
|
545
|
+
last_turn_at_ms,
|
|
546
|
+
size_bytes: st.size,
|
|
547
|
+
})
|
|
352
548
|
} catch {
|
|
353
549
|
// ignore individual file stat errors
|
|
354
550
|
}
|
|
355
551
|
}
|
|
356
|
-
//
|
|
357
|
-
|
|
552
|
+
// 消えた jsonl (削除・rotate) のキャッシュエントリを掃除し、dir 内キャッシュを有界に保つ。
|
|
553
|
+
if (fileMap.size > seenPaths.size) {
|
|
554
|
+
for (const key of fileMap.keys()) {
|
|
555
|
+
if (!seenPaths.has(key)) fileMap.delete(key)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// 会話ターン境界の新しい順 + limit 件に絞ってから preview 抽出 (全ファイル読みを避ける)
|
|
559
|
+
stats.sort((a, b) => b.last_turn_at_ms - a.last_turn_at_ms)
|
|
358
560
|
const top = stats.slice(0, limit)
|
|
359
561
|
const sessions = []
|
|
360
562
|
for (const s of top) {
|
|
@@ -1729,6 +1729,13 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1729
1729
|
this._mcpControlStarting = null
|
|
1730
1730
|
}
|
|
1731
1731
|
|
|
1732
|
+
/** _liveBySession の複合キー (T06271): cwd を含めることで、別 workspace の同一 session_id を
|
|
1733
|
+
* 別エントリに分離し、resume/reattach が cwd 境界を越えないことを保証する。session_id は
|
|
1734
|
+
* 本来ユニークだが、異常系 (別 cwd で同一 id) でも越境させないための機構キー。 */
|
|
1735
|
+
_liveKeyFor(cwd, sessionId) {
|
|
1736
|
+
return cwd && sessionId ? cwd + "\u0000" + sessionId : null
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1732
1739
|
/** 多端末共有: あるセッションに紐づく全端末 stream_id を sessions Map から外し、
|
|
1733
1740
|
* session_id 索引も掃除する。撤去 (reap / idle / shutdown) で 1 端末分だけ消すと
|
|
1734
1741
|
* 他端末キーが Map に残って孤児化するため、まとめて消す。 */
|
|
@@ -1741,8 +1748,9 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1741
1748
|
if (this.sessions.get(session.stream_id) === session) {
|
|
1742
1749
|
this.sessions.delete(session.stream_id)
|
|
1743
1750
|
}
|
|
1744
|
-
|
|
1745
|
-
|
|
1751
|
+
const curKey = this._liveKeyFor(session.cwd, session.sessionId)
|
|
1752
|
+
if (curKey && this._liveBySession.get(curKey) === session) {
|
|
1753
|
+
this._liveBySession.delete(curKey)
|
|
1746
1754
|
}
|
|
1747
1755
|
// SDK-P0-4 (2026-07-13): session_id 回転前に登録した旧キーも外す (現 sessionId しか見ないと
|
|
1748
1756
|
// 旧 id → 死んだ session のエントリが残り、attach が孤児を引き当てる)。
|
|
@@ -1765,8 +1773,25 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1765
1773
|
stopSessionsFor({ cwd, session_id } = {}) {
|
|
1766
1774
|
const victims = new Set()
|
|
1767
1775
|
if (session_id) {
|
|
1768
|
-
|
|
1769
|
-
|
|
1776
|
+
// 複合キー化 (T06271) で session_id 単独では引けないため values を走査して拾う。
|
|
1777
|
+
// ⚠️ cwd 境界 (T06271 レビュー6): cwd 指定時は session_id 照合も **同 cwd に限定** する。
|
|
1778
|
+
// 複合キー化後も別 cwd に同一 session_id の session が併存し得るため、全 cwd を union
|
|
1779
|
+
// すると A の cwd bind で B (別 cwd) の同一 R まで停止してしまう (実再現 stopped=2)。
|
|
1780
|
+
const idMatches = []
|
|
1781
|
+
for (const s of this._liveBySession.values()) {
|
|
1782
|
+
if (s.sessionId !== session_id) continue
|
|
1783
|
+
if (cwd && s.cwd !== cwd) continue
|
|
1784
|
+
idMatches.push(s)
|
|
1785
|
+
}
|
|
1786
|
+
if (!cwd && idMatches.length > 1) {
|
|
1787
|
+
// cwd 未指定で session_id が複数 cwd に一致 = どれを止めるべきか曖昧。推測停止しない。
|
|
1788
|
+
this.logger?.warn?.(
|
|
1789
|
+
{ session_id, matches: idMatches.length },
|
|
1790
|
+
"stopSessionsFor: session_id が複数 cwd に一致。cwd 未指定のため停止をスキップ (曖昧)",
|
|
1791
|
+
)
|
|
1792
|
+
} else {
|
|
1793
|
+
for (const s of idMatches) victims.add(s)
|
|
1794
|
+
}
|
|
1770
1795
|
}
|
|
1771
1796
|
if (cwd) {
|
|
1772
1797
|
for (const s of this._liveBySession.values()) {
|
|
@@ -1827,12 +1852,19 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1827
1852
|
if (this.sessions.has(stream_id)) {
|
|
1828
1853
|
throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
|
|
1829
1854
|
}
|
|
1855
|
+
// cwd を 1 回だけ解決し、reattach lookup / session 生成 / 索引登録で同じ値を使う
|
|
1856
|
+
// (複合キーの整合性を保つため)。
|
|
1857
|
+
const scwd = cwd || process.env.HOME || process.cwd()
|
|
1830
1858
|
// 常駐化: resume_session_id が示すセッションが (走行中 / idle 問わず) まだ生存していれば、
|
|
1831
1859
|
// 新規 query を起動せずそのセッションへ再アタッチする。これにより端末スリープ/再接続でも
|
|
1832
1860
|
// ターンは落ちず、以降のイベントは新しい stream_id 経由でライブに流れる
|
|
1833
1861
|
// (= 通常の生成中表示と同じ)。再接続前の確定分は browser の jsonl hydrate で復元する。
|
|
1834
1862
|
if (resumeSessionId) {
|
|
1835
|
-
|
|
1863
|
+
// ⚠️ cwd 境界 (T06271): _liveBySession は **複合キー (cwd+session_id)** なので、別 cwd の
|
|
1864
|
+
// 同一 session_id は別エントリに分離される。よって cwd=A の resume を cwd=B から
|
|
1865
|
+
// attach しても引き当たらず、越境 reattach が原理的に起きない (session_id は本来
|
|
1866
|
+
// ユニークだが異常系でも cwd を跨がない)。
|
|
1867
|
+
const live = this._liveBySession.get(this._liveKeyFor(scwd, resumeSessionId))
|
|
1836
1868
|
if (live && !live._closed) {
|
|
1837
1869
|
// 多端末共有 (CHAT_SHARED_ENABLED): 旧 stream_id を Map から消さない。同一セッションを
|
|
1838
1870
|
// 複数端末が購読する状態を作り、どの端末の input/permission/interrupt も解決できるように
|
|
@@ -1868,7 +1900,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1868
1900
|
}
|
|
1869
1901
|
const session = new ClaudeStreamSession({
|
|
1870
1902
|
stream_id,
|
|
1871
|
-
cwd:
|
|
1903
|
+
cwd: scwd,
|
|
1872
1904
|
model: model || null,
|
|
1873
1905
|
permissionMode: permissionMode || null,
|
|
1874
1906
|
maxTurns: typeof maxTurns === "number" ? maxTurns : null,
|
|
@@ -1894,14 +1926,17 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1894
1926
|
// 意図しない live session を引き当てる (+ Map リーク)。登録済みキーを覚えておき、
|
|
1895
1927
|
// 回転を検知したら旧キーを外してから張り替える。
|
|
1896
1928
|
if (session.sessionId) {
|
|
1929
|
+
// 複合キー (cwd+session_id)。別 cwd の同一 session_id は別キーになるため、B の onEvent が
|
|
1930
|
+
// A の索引を奪わない (T06271 レビュー5: cwd ガードだけでは onEvent の無条件 set が穴だった)。
|
|
1931
|
+
const key = this._liveKeyFor(session.cwd, session.sessionId)
|
|
1897
1932
|
const prevKey = session._liveKey
|
|
1898
|
-
if (prevKey && prevKey !==
|
|
1933
|
+
if (prevKey && prevKey !== key) {
|
|
1899
1934
|
if (this._liveBySession.get(prevKey) === session) {
|
|
1900
1935
|
this._liveBySession.delete(prevKey)
|
|
1901
1936
|
}
|
|
1902
1937
|
}
|
|
1903
|
-
session._liveKey =
|
|
1904
|
-
this._liveBySession.set(
|
|
1938
|
+
session._liveKey = key
|
|
1939
|
+
this._liveBySession.set(key, session)
|
|
1905
1940
|
}
|
|
1906
1941
|
},
|
|
1907
1942
|
onPermission: ({ tool_name, input, request_id }) => {
|
|
@@ -1955,6 +1990,18 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1955
1990
|
},
|
|
1956
1991
|
})
|
|
1957
1992
|
this.sessions.set(stream_id, session)
|
|
1993
|
+
// ⚠️ resume 索引を attach 時に **同期登録** する (T06271 レビュー3 major①)。従来は onEvent
|
|
1994
|
+
// (SDK が session_id を確定させた後) でしか _liveBySession に載せていなかったため、input 前に
|
|
1995
|
+
// 同一 resumeSessionId で 2 回 attach すると「索引が空の窓」で上の reattach 判定 (1835) をすり抜け、
|
|
1996
|
+
// 同じ session_id の別 session が 2 本でき、sendToStream が先頭へ推測注入していた。attach は
|
|
1997
|
+
// get(reattach)→set まで await を挟まず同期なので、ここで登録すれば 2 本目の attach は必ず
|
|
1998
|
+
// reattach 経路に入り重複が生じない。session_id が回転 (clear/compaction) したら onEvent が張り替える。
|
|
1999
|
+
// ⚠️ 複合キー (cwd+session_id) で登録するため、別 cwd の同一 session_id を奪わない (T06271)。
|
|
2000
|
+
if (resumeSessionId) {
|
|
2001
|
+
const key = this._liveKeyFor(scwd, resumeSessionId)
|
|
2002
|
+
session._liveKey = key
|
|
2003
|
+
this._liveBySession.set(key, session)
|
|
2004
|
+
}
|
|
1958
2005
|
this.logger?.info(
|
|
1959
2006
|
{ stream_id, cwd: session.cwd, model: session.model, resume: !!resumeSessionId },
|
|
1960
2007
|
"claude stream attached",
|
|
@@ -2303,6 +2350,61 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2303
2350
|
return true
|
|
2304
2351
|
}
|
|
2305
2352
|
|
|
2353
|
+
/**
|
|
2354
|
+
* 指定 session_id のライブセッションへメッセージを直接注入する (sibling-inject の
|
|
2355
|
+
* **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd は「cwd 内で最終活性が最新の
|
|
2356
|
+
* もの」をヒューリスティックに選ぶため、同一 cwd に複数の生きた claude セッションが
|
|
2357
|
+
* 同居すると意図しないセッション (詰まった巨大セッション等) へ誤爆する。呼び出し側が
|
|
2358
|
+
* describeLiveSessions で得た session_id を明示すれば、この曖昧さを回避できる。
|
|
2359
|
+
*
|
|
2360
|
+
* ⚠️ セキュリティ (cwd 境界): 呼び出し元 sibling-inject.mjs のトークンは **cwd スコープ**
|
|
2361
|
+
* (`verifySiblingToken(cwd, token)`) のため、cwd=A のトークンで cwd=B の session_id を
|
|
2362
|
+
* 指定した越境注入を防ぐには、**このメソッド側で cwd 一致を必須化する**必要がある
|
|
2363
|
+
* (session_id だけで全 sessions を走査すると、別 cwd のセッションへ注入できてしまう)。
|
|
2364
|
+
* よって cwd と session_id の **両方** が一致した場合のみ注入する。
|
|
2365
|
+
*
|
|
2366
|
+
* ⚠️ 一意性 (T06271 レビュー3): session_id は「1 論理セッションに一意」なはずだが、resume
|
|
2367
|
+
* 索引 (`_liveBySession`) を **attach 時に同期登録**するようにして初めて成立する不変条件。
|
|
2368
|
+
* それでも競合の防御網として、同一 cwd で session_id が複数一致したら **推測せず ambiguous**
|
|
2369
|
+
* を返す (codex の thread_id 重複と対称)。
|
|
2370
|
+
*
|
|
2371
|
+
* @param {string} cwd 呼び出し元がトークン検証した cwd (この cwd のセッションにのみ注入)
|
|
2372
|
+
* @param {string} sessionId 宛先 session_id (describeLiveSessions の session_id)
|
|
2373
|
+
* @param {string} text
|
|
2374
|
+
* @returns {{status: "delivered", session: object} | {status: "not_found"} | {status: "ambiguous", candidates: object[]}}
|
|
2375
|
+
*/
|
|
2376
|
+
sendToStream(cwd, sessionId, text) {
|
|
2377
|
+
if (!cwd || !sessionId || !text) return { status: "not_found" }
|
|
2378
|
+
const now = Date.now()
|
|
2379
|
+
const seen = new Set()
|
|
2380
|
+
const matches = []
|
|
2381
|
+
for (const session of this.sessions.values()) {
|
|
2382
|
+
if (seen.has(session)) continue
|
|
2383
|
+
seen.add(session)
|
|
2384
|
+
if (session.cwd !== cwd || session.sessionId !== sessionId) continue
|
|
2385
|
+
matches.push(session)
|
|
2386
|
+
}
|
|
2387
|
+
if (matches.length === 0) return { status: "not_found" }
|
|
2388
|
+
const describe = (s) => {
|
|
2389
|
+
let at = 0
|
|
2390
|
+
for (const t of s.lastActivityByStream?.values?.() ?? []) {
|
|
2391
|
+
if (t > at) at = t
|
|
2392
|
+
}
|
|
2393
|
+
return { kind: "claude", cwd: s.cwd, session_id: s.sessionId, idle_ms: at ? now - at : -1 }
|
|
2394
|
+
}
|
|
2395
|
+
if (matches.length > 1) {
|
|
2396
|
+
// 同一 session_id の別 session が複数 = resume 競合。推測せず ambiguous (誤爆防止)。
|
|
2397
|
+
return { status: "ambiguous", candidates: matches.map(describe) }
|
|
2398
|
+
}
|
|
2399
|
+
matches[0].sendMessage({ role: "user", content: text }).catch((err) => {
|
|
2400
|
+
this.logger?.error(
|
|
2401
|
+
{ session_id: sessionId, err: err?.message },
|
|
2402
|
+
"sendToStream: sendMessage threw unexpectedly",
|
|
2403
|
+
)
|
|
2404
|
+
})
|
|
2405
|
+
return { status: "delivered", session: describe(matches[0]) }
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2306
2408
|
/**
|
|
2307
2409
|
* `mcpServerStatus()` の結果に、needs-auth キャッシュにあるが結果へ含まれない
|
|
2308
2410
|
* サーバーを `needs-auth` として補完する。
|
|
@@ -535,10 +535,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
535
535
|
const result = await client.request("thread/list", {
|
|
536
536
|
cwd: cwd || undefined,
|
|
537
537
|
limit: limit || undefined,
|
|
538
|
-
// recency_at
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
538
|
+
// ⚠️ updated_at を使う (recency_at ではない)。recency_at は「読み込み(=スレッドを
|
|
539
|
+
// 開くだけ)も含めた使用感」の並び順なので、古いスレッドを開いただけでサイドバー最上位
|
|
540
|
+
// に浮上してしまう (症状⑨=実害)。サイドバーは「会話への書き込み(=ターン境界)」で
|
|
541
|
+
// 並べたいので、最終書き込み時刻の updated_at を採用する。
|
|
542
|
+
// (Claude 側 listSessions も jsonl 内の最終メッセージ timestamp = ターン境界に揃えた)
|
|
543
|
+
sortKey: "updated_at",
|
|
542
544
|
sortDirection: "desc",
|
|
543
545
|
})
|
|
544
546
|
const threads = Array.isArray(result?.data) ? result.data : []
|
|
@@ -546,10 +548,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
546
548
|
thread_id: t.id,
|
|
547
549
|
cwd: t.cwd || "",
|
|
548
550
|
preview: typeof t.preview === "string" ? t.preview.slice(0, 80) : "",
|
|
549
|
-
//
|
|
550
|
-
// (ms epoch) と同じ単位に揃え、frontend
|
|
551
|
+
// updatedAt 優先 (無ければ recencyAt fallback) を ms へ変換。claude 側
|
|
552
|
+
// ClaudeSessionSummary.mtime (ms epoch) と同じ単位に揃え、frontend が同じ並び替え
|
|
553
|
+
// ロジックを使い回せるようにする。並び順の source of truth は上の sortKey なので、
|
|
554
|
+
// ここは表示用の mtime も updatedAt を優先させて sortKey と一貫させる。
|
|
551
555
|
updated_at_ms:
|
|
552
|
-
(typeof t.
|
|
556
|
+
(typeof t.updatedAt === "number" ? t.updatedAt : t.recencyAt || 0) * 1000,
|
|
553
557
|
}))
|
|
554
558
|
} finally {
|
|
555
559
|
client.stop()
|
|
@@ -894,6 +898,80 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
894
898
|
return true
|
|
895
899
|
}
|
|
896
900
|
|
|
901
|
+
/**
|
|
902
|
+
* 指定した id (stream_id もしくは thread_id) のライブセッションへ直接注入する
|
|
903
|
+
* (sibling-inject の **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd は cwd 内で
|
|
904
|
+
* 最終活性が最新のものを選ぶヒューリスティックのため、同一 cwd に複数の生きた codex
|
|
905
|
+
* セッションが同居すると誤爆する。呼び出し側が describeLiveSessions で得た id を明示
|
|
906
|
+
* すれば曖昧さを回避できる。
|
|
907
|
+
*
|
|
908
|
+
* stream_id は WS 再接続 / in-memory reattach で付け替わる (不安定) 一方、thread_id は
|
|
909
|
+
* 会話に固定 (安定) なので、**両方を受け付け** どちらで指定しても解決できるようにする。
|
|
910
|
+
* Claude 版 `sendToStream` の対。
|
|
911
|
+
*
|
|
912
|
+
* ⚠️ セキュリティ (cwd 境界): 呼び出し元 sibling-inject.mjs のトークンは **cwd スコープ**
|
|
913
|
+
* のため、cwd=A のトークンで cwd=B の stream_id/thread_id を指定した越境注入を防ぐには、
|
|
914
|
+
* **このメソッド側で cwd 一致を必須化する** (id だけで全 sessions を走査すると別 cwd の
|
|
915
|
+
* セッションへ注入できてしまう)。cwd と id の **両方** が一致した場合のみ注入する。
|
|
916
|
+
*
|
|
917
|
+
* ⚠️ 一意性 (T06271 レビュー major③): stream_id は各 live app-server で一意だが、
|
|
918
|
+
* **thread_id は一意でない** — 同一 thread を複数回 resume/handover すると同じ thread_id を
|
|
919
|
+
* 持つ live セッションが同一 cwd に複数併存し得る。そのため:
|
|
920
|
+
* 1. まず stream_id の完全一致を優先 (常に一意に定まる)
|
|
921
|
+
* 2. stream_id で当たらなければ thread_id で照合し、**複数一致なら注入せず ambiguous を返す**
|
|
922
|
+
* (Map 先頭を選ぶと明示指定が再び推測になるため)
|
|
923
|
+
*
|
|
924
|
+
* @param {string} cwd 呼び出し元がトークン検証した cwd (この cwd のセッションにのみ注入)
|
|
925
|
+
* @param {string} id 宛先 stream_id (一意・推奨) または thread_id
|
|
926
|
+
* @param {string} text
|
|
927
|
+
* @returns {{status: "delivered", session: object} | {status: "not_found"} | {status: "ambiguous", candidates: object[]}}
|
|
928
|
+
*/
|
|
929
|
+
sendToStream(cwd, id, text) {
|
|
930
|
+
if (!cwd || !id || !text) return { status: "not_found" }
|
|
931
|
+
let bySid = null
|
|
932
|
+
const byThread = []
|
|
933
|
+
for (const session of this.sessions.values()) {
|
|
934
|
+
if (session.dead || session.cwd !== cwd) continue
|
|
935
|
+
if (session.stream_id === id) {
|
|
936
|
+
bySid = session
|
|
937
|
+
break // stream_id は一意なので即確定
|
|
938
|
+
}
|
|
939
|
+
if (session.threadId === id) byThread.push(session)
|
|
940
|
+
}
|
|
941
|
+
let target = bySid
|
|
942
|
+
if (!target) {
|
|
943
|
+
if (byThread.length > 1) {
|
|
944
|
+
return { status: "ambiguous", candidates: byThread.map((s) => this._describe(s)) }
|
|
945
|
+
}
|
|
946
|
+
target = byThread[0] || null
|
|
947
|
+
}
|
|
948
|
+
if (!target) return { status: "not_found" }
|
|
949
|
+
// detach 済み warm セッションでも、明示指定で使うなら「新しい持ち主が付いた」とみなす
|
|
950
|
+
// (sendToCwd と同じ扱い)。
|
|
951
|
+
target.detachRequested = false
|
|
952
|
+
this._touch(target)
|
|
953
|
+
this.input({ stream_id: target.stream_id, message: { role: "user", content: text } }).catch(
|
|
954
|
+
(err) => {
|
|
955
|
+
this.logger?.error(
|
|
956
|
+
{ id, stream_id: target.stream_id, err: err?.message },
|
|
957
|
+
"sendToStream: input threw unexpectedly",
|
|
958
|
+
)
|
|
959
|
+
},
|
|
960
|
+
)
|
|
961
|
+
return { status: "delivered", session: this._describe(target) }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** describeLiveSessions と同形の 1 セッション記述子 (sendToStream の結果整形に共用)。 */
|
|
965
|
+
_describe(session) {
|
|
966
|
+
return {
|
|
967
|
+
kind: "codex",
|
|
968
|
+
cwd: session.cwd,
|
|
969
|
+
stream_id: session.stream_id,
|
|
970
|
+
thread_id: session.threadId || null,
|
|
971
|
+
busy: !!session.activeTurnId,
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
897
975
|
/**
|
|
898
976
|
* Stage 5: セッションが「保護対象(reap 不可)」か。= **healthy な running turn**。
|
|
899
977
|
* activeTurnId が立っており、かつ app-server 通知が STUCK_TTL 以内に届いている(=生きている
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -16,9 +16,17 @@
|
|
|
16
16
|
* `src/sibling-token.mjs` の cwd スコープ capability トークンによる検証を必須とする。
|
|
17
17
|
* トークンが一致しない要求は無視し (実行せず削除)、warn ログのみ残す。
|
|
18
18
|
*
|
|
19
|
-
* 契約: request file `<request_id>.json`
|
|
19
|
+
* 契約: request file `<request_id>.json` は op で 3 形態 (2026-07-19 / T06271):
|
|
20
|
+
* - 通常注入 (op 無し): `{cwd, target_cli_kind, text, token}`
|
|
21
|
+
* cwd 内が 1 本なら注入、複数なら黙って推測せず `ambiguous_target` + 候補一覧。
|
|
22
|
+
* - 明示注入: `{op:"inject", cwd, target_cli_kind, token, target_session_id, message}`
|
|
23
|
+
* 本文は **top-level text ではなく `message`** に入れる。これにより旧 hub-agent は
|
|
24
|
+
* `!text` で `invalid_request` を返し (推測注入せず fail-closed)、明示宛先を解釈できる
|
|
25
|
+
* 新 agent だけが処理する (probe を挟まず単一リクエストで TOCTOU を排除)。
|
|
26
|
+
* 宛先は session_id(claude) / stream_id・thread_id(codex) を cwd 一致下に確定解決。
|
|
27
|
+
* - 一覧: `{op:"list", cwd, target_cli_kind, token}` → `{ok, live_sessions}` (--list 用)。
|
|
20
28
|
* 処理後は必ず削除し (成否問わず)、呼び出し側が待ち受けられるよう
|
|
21
|
-
* `<request_id>.result.json` = `{ok, error?}` を書く。
|
|
29
|
+
* `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?}` を書く。
|
|
22
30
|
*/
|
|
23
31
|
|
|
24
32
|
import { watch } from "node:fs"
|
|
@@ -100,23 +108,104 @@ export class SiblingInjectBridge {
|
|
|
100
108
|
return
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
|
|
111
|
+
// 契約の 3 形態を op で判別する:
|
|
112
|
+
// - op:"list" → 候補列挙 (top-level text 無し)
|
|
113
|
+
// - op:"inject" → 明示宛先 (target_session_id + message、top-level text は **持たない**)
|
|
114
|
+
// - op 無し → 通常 (cwd 最新、top-level text あり) ← 旧 agent と後方互換
|
|
115
|
+
// ⚠️ 明示宛先を「op:inject + message (top-level text 無し)」にするのが fail-closed の肝
|
|
116
|
+
// (T06271 レビュー major①): 旧 hub-agent は `!text` で invalid_request を返すため、
|
|
117
|
+
// 未知フィールドを無視して sendToCwd 推測注入する事故が **単一リクエストで原理的に
|
|
118
|
+
// 起きない** (probe→注入の TOCTOU を排除)。
|
|
119
|
+
const { op, cwd, target_cli_kind, text, message, token, target_session_id } = body || {}
|
|
120
|
+
const isList = op === "list"
|
|
121
|
+
const isExplicit = op === "inject"
|
|
104
122
|
let result
|
|
105
|
-
|
|
123
|
+
const badKind = target_cli_kind !== "claude" && target_cli_kind !== "codex"
|
|
124
|
+
const badBody = isList
|
|
125
|
+
? false
|
|
126
|
+
: isExplicit
|
|
127
|
+
? !target_session_id || !message // 明示は id と message 必須 (空指定 fail-open を防ぐ)
|
|
128
|
+
: !text // 通常は top-level text 必須 (=旧契約)
|
|
129
|
+
if (!cwd || badKind || badBody) {
|
|
106
130
|
result = { ok: false, error: "invalid_request" }
|
|
107
131
|
} else if (!verifySiblingToken(cwd, token)) {
|
|
108
132
|
this.logger?.warn({ cwd, request_id }, "sibling-inject: token mismatch, request denied")
|
|
109
133
|
result = { ok: false, error: "invalid_token" }
|
|
110
134
|
} else {
|
|
111
135
|
const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
if (isList) {
|
|
137
|
+
// 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
|
|
138
|
+
result = { ok: true, live_sessions: bridge?.describeLiveSessions?.(cwd) ?? [] }
|
|
139
|
+
} else if (isExplicit) {
|
|
140
|
+
// 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
|
|
141
|
+
// sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
|
|
142
|
+
const r = bridge?.sendToStream?.(cwd, target_session_id, message) ?? { status: "not_found" }
|
|
143
|
+
if (r.status === "delivered") {
|
|
144
|
+
result = { ok: true, delivered_to: r.session }
|
|
145
|
+
} else if (r.status === "ambiguous") {
|
|
146
|
+
// 宛先 id が同一 cwd の複数 live セッションに一致し一意に定まらない。
|
|
147
|
+
// ⚠️ 解消手段は CLI で異なる (T06271 レビュー4 minor):
|
|
148
|
+
// - codex: stream_id は一意なので stream_id で名指しできる
|
|
149
|
+
// - claude: 一意な宛先指定子が無い (session_id は重複、stream_id は非公開) ため
|
|
150
|
+
// --to では解消不能。Cockpit で該当チャットを開き直す / 再一覧を案内する
|
|
151
|
+
result = {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: "ambiguous_target",
|
|
154
|
+
candidates: r.candidates,
|
|
155
|
+
live_sessions: r.candidates,
|
|
156
|
+
hint:
|
|
157
|
+
target_cli_kind === "codex"
|
|
158
|
+
? `指定した id (${target_session_id}) はこの cwd で複数の live セッションに一致します。` +
|
|
159
|
+
`stream_id で名指ししてください (--list で確認)。`
|
|
160
|
+
: `指定した session_id (${target_session_id}) がこの cwd で複数の live セッションに ` +
|
|
161
|
+
`一致します。Claude は一意な宛先指定子が無いため --to では解消できません。Cockpit で` +
|
|
162
|
+
`対象 cwd の Claude チャットを開き直して1本に整理してから再送してください` +
|
|
163
|
+
`(通常は attach 時の重複解消で発生しません)。`,
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
result = {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: "target_session_not_found",
|
|
169
|
+
target_session_id,
|
|
170
|
+
// ⚠️ _diagnose の hint は「対象 CLI 不在」前提で id 不一致 (別 cwd 指定含む) では矛盾
|
|
171
|
+
// する ("claude は無い" と誤表示) ため、id 不一致専用の hint に差し替える。
|
|
172
|
+
live_sessions: this._diagnose(cwd, target_cli_kind).live_sessions,
|
|
173
|
+
hint:
|
|
174
|
+
`指定した宛先 id (${target_session_id}) はこの cwd (${cwd}) の生きた ` +
|
|
175
|
+
`${target_cli_kind} セッションに見つかりません (別 cwd の id は越境注入できません)。` +
|
|
176
|
+
`--list で候補を確認してください。`,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// 宛先省略 (通常): 同一 cwd の生きたセッション数で分岐。
|
|
181
|
+
// 0 本 → no_live_session / 1 本 → 注入 / 複数 → 黙って推測せず ambiguous_target。
|
|
182
|
+
const sameCwd = bridge?.describeLiveSessions?.(cwd) ?? []
|
|
183
|
+
if (sameCwd.length > 1) {
|
|
184
|
+
result = {
|
|
185
|
+
ok: false,
|
|
186
|
+
error: "ambiguous_target",
|
|
187
|
+
candidates: sameCwd,
|
|
188
|
+
live_sessions: sameCwd,
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
const sent = bridge?.sendToCwd?.(cwd, text) ?? false
|
|
192
|
+
result = sent
|
|
193
|
+
? { ok: true, delivered_to: sameCwd[0] ?? null }
|
|
194
|
+
: { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
116
197
|
}
|
|
117
198
|
|
|
118
199
|
this.logger?.[result.ok ? "info" : "warn"]?.(
|
|
119
|
-
{
|
|
200
|
+
{
|
|
201
|
+
request_id,
|
|
202
|
+
cwd,
|
|
203
|
+
target_cli_kind,
|
|
204
|
+
op: op || "normal",
|
|
205
|
+
...(result.ok
|
|
206
|
+
? { delivered_to: result.delivered_to }
|
|
207
|
+
: { error: result.error, live_sessions: result.live_sessions }),
|
|
208
|
+
},
|
|
120
209
|
result.ok ? "sibling-inject delivered" : "sibling-inject failed",
|
|
121
210
|
)
|
|
122
211
|
await this._writeResult(request_id, result)
|
|
@@ -136,22 +225,26 @@ export class SiblingInjectBridge {
|
|
|
136
225
|
* ⚠️ ここに出るのは hub-agent が spawn した **チャットセッションのみ**。tmux 配下で
|
|
137
226
|
* 動く TUI codex は ask-sibling の宛先にならないため、一覧が空でも「codex は画面上
|
|
138
227
|
* 動いている」ことは普通に起きる。その旨を hint に明記する。
|
|
228
|
+
*
|
|
229
|
+
* ⚠️ セキュリティ (T06271 レビュー3 major②): 返す live_sessions は **この cwd のものだけ**に
|
|
230
|
+
* 限定する。呼び出し元トークンは cwd スコープなので、全 cwd の session id を返すと別
|
|
231
|
+
* workspace の cwd/セッション存在を漏らす (cwd scoped token での越境情報漏洩)。cwd 指定で
|
|
232
|
+
* describeLiveSessions を呼び、同一 cwd のみを材料にする。
|
|
139
233
|
* @param {string} cwd
|
|
140
234
|
* @param {string} targetKind
|
|
141
235
|
*/
|
|
142
236
|
_diagnose(cwd, targetKind) {
|
|
143
|
-
const
|
|
144
|
-
...(this.claudeBridge?.describeLiveSessions?.() ?? []),
|
|
145
|
-
...(this.codexBridge?.describeLiveSessions?.() ?? []),
|
|
237
|
+
const sameCwd = [
|
|
238
|
+
...(this.claudeBridge?.describeLiveSessions?.(cwd) ?? []),
|
|
239
|
+
...(this.codexBridge?.describeLiveSessions?.(cwd) ?? []),
|
|
146
240
|
]
|
|
147
|
-
const sameCwd = all.filter((s) => s.cwd === cwd)
|
|
148
241
|
const hint = sameCwd.length
|
|
149
242
|
? `この cwd には ${sameCwd.map((s) => s.kind).join("/")} のチャットセッションはあるが ${targetKind} は無い。`
|
|
150
243
|
: `この cwd には hub-agent 管理下のチャットセッションが 1 本も無い。` +
|
|
151
244
|
`tmux の TUI codex は ask-sibling の宛先にならない (hub-agent が spawn した` +
|
|
152
245
|
` Cockpit のチャットセッションのみが対象)。Cockpit のブラウザで対象 cwd の` +
|
|
153
246
|
` ${targetKind} チャットを開き直してから再送すること。`
|
|
154
|
-
return { live_sessions:
|
|
247
|
+
return { live_sessions: sameCwd, hint }
|
|
155
248
|
}
|
|
156
249
|
|
|
157
250
|
/** 結果ファイルを atomic write する (呼び出し側 CLI がポーリングする)。 */
|