@cocorograph/hub-agent 0.7.53 → 0.7.56
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-stream-bridge.mjs +201 -35
- package/src/hub-bundle.mjs +11 -4
- package/src/main.mjs +9 -40
- package/src/model-capability-report.mjs +0 -118
package/package.json
CHANGED
|
@@ -184,20 +184,6 @@ export function getModelCapability(model) {
|
|
|
184
184
|
return _modelCapabilities.get(model) ?? null
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
/** レジストリを Hub 報告用 [{model_name, supports_effort, supports_xhigh_effort}] へ変換する。
|
|
188
|
-
* 第2段 (2026-07-11): backend の apply_capability_report が期待する形。 */
|
|
189
|
-
export function getReportModels() {
|
|
190
|
-
const out = []
|
|
191
|
-
for (const [name, cap] of _modelCapabilities) {
|
|
192
|
-
out.push({
|
|
193
|
-
model_name: name,
|
|
194
|
-
supports_effort: cap.supportsEffort === true,
|
|
195
|
-
supports_xhigh_effort: cap.supportsXhighEffort === true,
|
|
196
|
-
})
|
|
197
|
-
}
|
|
198
|
-
return out
|
|
199
|
-
}
|
|
200
|
-
|
|
201
187
|
/** テスト用: レジストリをクリアする。 */
|
|
202
188
|
export function _resetModelCapabilities() {
|
|
203
189
|
_modelCapabilities.clear()
|
|
@@ -285,6 +271,32 @@ function isSessionIdleEvent(msg) {
|
|
|
285
271
|
)
|
|
286
272
|
}
|
|
287
273
|
|
|
274
|
+
/**
|
|
275
|
+
* SDK-P0-4 (2026-07-13): SDK の権威的な「ターン開始」シグナル。
|
|
276
|
+
* sdk.d.ts (SDKSessionStateChangedMessage) の state は 'idle' | 'running' | 'requires_action'。
|
|
277
|
+
* `running` は SDK が新しい input の処理を実際に開始したときにだけ流れるため、
|
|
278
|
+
* **前ターンの遅延イベントでは絶対に立たない**。これがターン帰属の唯一の確実な境界になる。
|
|
279
|
+
*
|
|
280
|
+
* なぜ必要か: 旧ガード `_turnProgressed` は「現ターンが 1 件でもイベントを出したか」だったが、
|
|
281
|
+
* これは「**現ターンの**イベントが来た」ことを保証しない。abort (interrupt) 直後に drain で
|
|
282
|
+
* 次ターンを開始すると、中断されたターンの in-flight イベント (stream_event の残り delta /
|
|
283
|
+
* 実行中だった tool の user メッセージ等) が 1 件届くだけで _turnProgressed=true になり、
|
|
284
|
+
* その直後に届く**前ターンの idle** が新ターンを終端扱いして撃ち抜いていた (二重 drain →
|
|
285
|
+
* 実行中ターンの InputQueue へ追加 push → FIFO 崩れ・応答取り違え)。
|
|
286
|
+
*/
|
|
287
|
+
function isSessionRunningEvent(msg) {
|
|
288
|
+
return (
|
|
289
|
+
msg?.type === "system" &&
|
|
290
|
+
msg?.subtype === "session_state_changed" &&
|
|
291
|
+
msg?.state === "running"
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** SDK-P0-4: 「前ターンの遅延 idle」を待つ猶予 (ms)。generator のイベント順序は保証されるので
|
|
296
|
+
* 遅延 idle は通常ミリ秒で届く。ここを過ぎても届かなければガードを解除し、現ターンの idle を
|
|
297
|
+
* 正しく終端として扱う (ガードが固着して _busy が永久 true になる = T06052 の再発を防ぐ安全弁)。 */
|
|
298
|
+
const STALE_IDLE_GUARD_MS = 5000
|
|
299
|
+
|
|
288
300
|
/**
|
|
289
301
|
* 1 stream に対応する Claude セッション (per-message query モデル)。
|
|
290
302
|
*/
|
|
@@ -418,18 +430,40 @@ class ClaudeStreamSession {
|
|
|
418
430
|
this._turnSettled = false
|
|
419
431
|
/** SDK-P0-3: 現ターンが 1 件でもストリームイベントを出したか。
|
|
420
432
|
* 「前ターンの遅延 idle が、drain で始まったばかりの次ターンを誤終了させる」レースの
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
* =
|
|
433
|
+
* 一次ガード。generator のイベント順序保証から導けるのは **否定方向だけ** である点に注意:
|
|
434
|
+
* 「まだ 1 件も出ていない (=false) → この idle は前ターンのもの」は正しいが、逆の
|
|
435
|
+
* 「1 件出ている (=true) → この idle は現ターンのもの」は導けない (前ターンの遅延イベントでも
|
|
436
|
+
* true になるため)。その穴は SDK-P0-4 の _staleIdleExpected / _turnRunningSeen で塞ぐ。
|
|
437
|
+
* ターン開始時に false へ戻す。 */
|
|
424
438
|
this._turnProgressed = false
|
|
439
|
+
/** SDK-P0-4 (2026-07-13): ターン連番。ログと帰属デバッグ用 (_beginResidentTurn で ++)。 */
|
|
440
|
+
this._turnSeq = 0
|
|
441
|
+
/** SDK-P0-4: 「直前ターンの idle がこれから 1 件届きうる」状態。result で settle したとき、
|
|
442
|
+
* および abort (interrupt) でターンを畳んだときに arm する (SDK は result → idle の順で
|
|
443
|
+
* 流し、interrupt 後にも中断ターンの idle を流すため)。arm 中に届いた idle は
|
|
444
|
+
* **前ターンのもの**として 1 件だけ握り潰し、現ターンを終端しない。
|
|
445
|
+
* 解除条件: (a) idle を 1 件消費した (b) SDK の running が届いた (= 前ターンの全イベントは
|
|
446
|
+
* 順序保証により既に通過済み) (c) 現ターンの result が届いた (d) STALE_IDLE_GUARD_MS 経過。 */
|
|
447
|
+
this._staleIdleExpected = false
|
|
448
|
+
/** SDK-P0-4: _staleIdleExpected の安全弁タイマー (固着による _busy 永久 true を防ぐ)。 */
|
|
449
|
+
this._staleIdleTimer = null
|
|
450
|
+
/** SDK-P0-4: 現ターンで SDK の session_state_changed:running を観測したか。
|
|
451
|
+
* 観測済みなら「以降に届く idle は確実に現ターンのもの」と断定できる。 */
|
|
452
|
+
this._turnRunningSeen = false
|
|
425
453
|
/** detach 済みフラグ (新規ターン受付停止) */
|
|
426
454
|
this._closed = false
|
|
427
455
|
/** 実行中ターンの完走を待ってからクローズする予約フラグ (graceful detach 用)。
|
|
428
456
|
* 端末スリープ / ネット断で browser が切れても、明示的な中断 (interrupt) が
|
|
429
457
|
* 無い限りターンを落とさず最後まで走らせるために使う。 */
|
|
430
458
|
this._reapAfterTurn = false
|
|
431
|
-
/** 現在ターンの AbortController (interrupt 用) */
|
|
459
|
+
/** 現在ターンの AbortController (per-message の interrupt 用) */
|
|
432
460
|
this._abortController = null
|
|
461
|
+
/** SDK-P0-4 (2026-07-13): 常駐 query の AbortController (最終手段の強制終了用)。
|
|
462
|
+
* 常駐は interrupt() で「現ターンだけ」を中断するのが正規経路だが、control request が
|
|
463
|
+
* 失敗/無応答のとき (CLI ハング・control channel 断) に generator を畳む手段が無いと
|
|
464
|
+
* close()/shutdown() で子プロセスが残留する。per-message と同じく AbortController を
|
|
465
|
+
* options へ渡し、interrupt が reject したときと close() のときに最終手段として abort する。 */
|
|
466
|
+
this._residentAbortController = null
|
|
433
467
|
|
|
434
468
|
/** jsonl ライブ追従 watcher (0.6.5)。外部 (tmux 等) の追記を拾う。 */
|
|
435
469
|
this._watcher = null
|
|
@@ -491,8 +525,20 @@ class ClaudeStreamSession {
|
|
|
491
525
|
)
|
|
492
526
|
}
|
|
493
527
|
|
|
494
|
-
/** 現在の sessionId の jsonl を watch する (既に同じものを watch 中なら何もしない)。
|
|
528
|
+
/** 現在の sessionId の jsonl を watch する (既に同じものを watch 中なら何もしない)。
|
|
529
|
+
*
|
|
530
|
+
* SDK-P0-4 (2026-07-13): **常駐 (resident) セッションでは watcher を張らない**。
|
|
531
|
+
* _runResidentQuery の docstring が「常駐では query stream が唯一のイベント源のため
|
|
532
|
+
* watcher は張らない」と宣言しているのに、コンストラクタの `if (this.sessionId) _ensureWatch()`
|
|
533
|
+
* が resume セッション (= sessionId 非 null) で無条件に watcher を張っており、
|
|
534
|
+
* HUB_AGENT_CHAT_RESIDENT_RESUME=1 のとき **SDK ストリームと jsonl watcher の二重ソース**に
|
|
535
|
+
* なっていた。per-message は finally で `_busy=true` のまま skipToEnd() を待ってから
|
|
536
|
+
* busy を落とすことで二重配信を防いでいるが (下の _runPerMessage 参照)、常駐の終端
|
|
537
|
+
* (_settleResidentTurn) は同期で _busy=false にするだけで skipToEnd を持たないため、
|
|
538
|
+
* ターン末尾の書き込みを watcher が読み直して二重配信しうる。宣言した不変条件をここで
|
|
539
|
+
* 機械的に強制する (将来 CHAT_RESIDENT_RESUME を既定 ON にする際のブロッカーだった)。 */
|
|
495
540
|
_ensureWatch() {
|
|
541
|
+
if (this._residentEligible) return
|
|
496
542
|
if (this._closed || !this.sessionId || !this.cwd) return
|
|
497
543
|
if (this._watchedSessionId === this.sessionId && this._watcher) return
|
|
498
544
|
// 旧 watcher を畳む
|
|
@@ -1217,6 +1263,34 @@ class ClaudeStreamSession {
|
|
|
1217
1263
|
this._busy = true
|
|
1218
1264
|
this._turnSettled = false
|
|
1219
1265
|
this._turnProgressed = false
|
|
1266
|
+
this._turnRunningSeen = false
|
|
1267
|
+
this._turnSeq += 1
|
|
1268
|
+
// ⚠️ _staleIdleExpected はここでリセットしない。ターン開始は「前ターンを settle した
|
|
1269
|
+
// 直後の同期 drain」から呼ばれるため、ここでクリアすると前ターンの遅延 idle を握り潰す
|
|
1270
|
+
// ガードが自ら消えてしまう (それが SDK-P0-3 の穴だった)。解除は _clearStaleIdleGuard()
|
|
1271
|
+
// (idle 消費 / running 到着 / 現ターン result / タイムアウト) でのみ行う。
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** SDK-P0-4: 「直前ターンの遅延 idle を 1 件だけ握り潰す」ガードを張る。
|
|
1275
|
+
* 安全弁として STALE_IDLE_GUARD_MS で自動解除する (ガード固着による _busy 永久 true を防ぐ)。 */
|
|
1276
|
+
_armStaleIdleGuard() {
|
|
1277
|
+
this._staleIdleExpected = true
|
|
1278
|
+
if (this._staleIdleTimer) clearTimeout(this._staleIdleTimer)
|
|
1279
|
+
this._staleIdleTimer = setTimeout(() => {
|
|
1280
|
+
this._staleIdleTimer = null
|
|
1281
|
+
this._staleIdleExpected = false
|
|
1282
|
+
}, STALE_IDLE_GUARD_MS)
|
|
1283
|
+
// Node の event loop を引き止めない (プロセス終了をブロックしない)。
|
|
1284
|
+
this._staleIdleTimer?.unref?.()
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** SDK-P0-4: 遅延 idle ガードを解除する。 */
|
|
1288
|
+
_clearStaleIdleGuard() {
|
|
1289
|
+
this._staleIdleExpected = false
|
|
1290
|
+
if (this._staleIdleTimer) {
|
|
1291
|
+
clearTimeout(this._staleIdleTimer)
|
|
1292
|
+
this._staleIdleTimer = null
|
|
1293
|
+
}
|
|
1220
1294
|
}
|
|
1221
1295
|
|
|
1222
1296
|
/** SDK-P0-3 (2026-07-10): resident の「ターン終端」を 1 箇所に集約する (result / idle 共通)。
|
|
@@ -1230,6 +1304,11 @@ class ClaudeStreamSession {
|
|
|
1230
1304
|
this._turnSettled = true
|
|
1231
1305
|
denyPending?.("turn ended")
|
|
1232
1306
|
this._busy = false
|
|
1307
|
+
// SDK-P0-4: result で畳んだターンは、この後に自分の idle を必ず 1 件流す
|
|
1308
|
+
// (sdk.d.ts: "'idle' fires after heldBackResult flushes … authoritative turn-over signal")。
|
|
1309
|
+
// drain が同期で次ターンを開始するため、その idle が新ターンに誤帰属しないようガードを張る。
|
|
1310
|
+
// idle 自身で畳んだ場合は既にその idle を消費済みなので張らない。
|
|
1311
|
+
if (reason === "result") this._armStaleIdleGuard()
|
|
1233
1312
|
// 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
|
|
1234
1313
|
// ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
|
|
1235
1314
|
// async。for await ループ内からは fire-and-forget で呼ぶ (最初の await より前に
|
|
@@ -1285,11 +1364,16 @@ class ClaudeStreamSession {
|
|
|
1285
1364
|
* 常駐では query stream が唯一のイベント源のため watcher は張らない (過去確定分は browser が
|
|
1286
1365
|
* history.request で hydrate 済み。resume で query stream が過去行を replay しない前提)。 */
|
|
1287
1366
|
async _runResidentQuery() {
|
|
1367
|
+
// SDK-P0-4 (2026-07-13): 常駐にも AbortController を渡す (per-message には元からある)。
|
|
1368
|
+
// 正規の中断は interrupt() だが、control request が失敗/無応答のときに generator を畳む
|
|
1369
|
+
// 最終手段が無いと、close()/shutdown() で CLI 子プロセスが残留する。
|
|
1370
|
+
this._residentAbortController = new AbortController()
|
|
1288
1371
|
const options = {
|
|
1289
1372
|
cwd: this.cwd,
|
|
1290
1373
|
env: siblingEnvFor(this.cwd),
|
|
1291
1374
|
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
1292
1375
|
includePartialMessages: true,
|
|
1376
|
+
abortController: this._residentAbortController,
|
|
1293
1377
|
stderr: (data) => this._stderrHandler(data),
|
|
1294
1378
|
}
|
|
1295
1379
|
if (this.model) options.model = this.model
|
|
@@ -1321,6 +1405,10 @@ class ClaudeStreamSession {
|
|
|
1321
1405
|
}
|
|
1322
1406
|
if (msg?.type === "result") {
|
|
1323
1407
|
if (typeof msg.session_id === "string") this.sessionId = msg.session_id
|
|
1408
|
+
// SDK-P0-4: 現ターンの result が届いた = 前ターンの遅延 idle は順序保証により
|
|
1409
|
+
// 既に通過済み (届いたなら消費済み / 届かないなら二度と来ない)。ガードを解除してから
|
|
1410
|
+
// settle する (settle が result 用のガードを張り直す)。
|
|
1411
|
+
this._clearStaleIdleGuard()
|
|
1324
1412
|
// ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
|
|
1325
1413
|
this._settleResidentTurn("result", denyPending)
|
|
1326
1414
|
} else if (isSessionIdleEvent(msg)) {
|
|
@@ -1329,13 +1417,18 @@ class ClaudeStreamSession {
|
|
|
1329
1417
|
// _pendingMessages に退避されたまま (drain 契機の result が永久に来ないので)
|
|
1330
1418
|
// 永久滞留していた ("resident busy, message queued" が出続ける / リロードで消える)。
|
|
1331
1419
|
//
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1337
|
-
|
|
1338
|
-
|
|
1420
|
+
// SDK-P0-4 (2026-07-13) 帰属ガード: この idle は「現ターンのもの」か「前ターン
|
|
1421
|
+
// (result で畳んだ / interrupt で中断した) の遅延分」か。
|
|
1422
|
+
// 1) _staleIdleExpected=true → 前ターンの遅延 idle。1 件だけ握り潰して現ターンは
|
|
1423
|
+
// 継続させる (旧実装はここで新ターンを撃ち抜いていた)。
|
|
1424
|
+
// 2) それ以外で _busy かつ (running を観測済み or 何かイベントを出した) → 現ターンの終端。
|
|
1425
|
+
if (this._staleIdleExpected) {
|
|
1426
|
+
this._clearStaleIdleGuard()
|
|
1427
|
+
this.logger?.debug?.(
|
|
1428
|
+
{ stream_id: this.stream_id, turn: this._turnSeq },
|
|
1429
|
+
"stale idle from previous turn ignored",
|
|
1430
|
+
)
|
|
1431
|
+
} else if (this._busy && (this._turnRunningSeen || this._turnProgressed)) {
|
|
1339
1432
|
this._settleResidentTurn("idle", denyPending)
|
|
1340
1433
|
// per-message の RC-4 と対称に、チャット信号 (サイドバードット) を
|
|
1341
1434
|
// processing→waiting へ落とす。result 経由なら main.mjs が result を見て
|
|
@@ -1347,8 +1440,15 @@ class ClaudeStreamSession {
|
|
|
1347
1440
|
}
|
|
1348
1441
|
}
|
|
1349
1442
|
} else {
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1443
|
+
// SDK-P0-4: running は「SDK が新しい input の処理を実際に開始した」ことの権威的な
|
|
1444
|
+
// 証跡で、前ターンの遅延イベントでは絶対に立たない。これを観測した時点で、
|
|
1445
|
+
// 前ターン由来の遅延 idle はもう届かない (順序保証) のでガードを解除できる。
|
|
1446
|
+
if (isSessionRunningEvent(msg)) {
|
|
1447
|
+
this._turnRunningSeen = true
|
|
1448
|
+
this._clearStaleIdleGuard()
|
|
1449
|
+
}
|
|
1450
|
+
// 現ターンが「進行した」証跡 (一次ガード)。init / assistant / stream_event / user、
|
|
1451
|
+
// および session_state_changed の running / requires_action もここに含む。
|
|
1352
1452
|
this._turnProgressed = true
|
|
1353
1453
|
}
|
|
1354
1454
|
try {
|
|
@@ -1418,17 +1518,42 @@ class ClaudeStreamSession {
|
|
|
1418
1518
|
// 常駐 query は interrupt() で現ターンのみ中断する (query は継続、次 input 受付継続)。
|
|
1419
1519
|
if (this._residentQuery && typeof this._residentQuery.interrupt === "function") {
|
|
1420
1520
|
const wasBusy = this._busy
|
|
1521
|
+
// SDK-P0-4 (2026-07-13): Query.interrupt() は control request の往復を伴う **async
|
|
1522
|
+
// メソッド** (Promise を返す)。同期 try/catch では reject を捕まえられず unhandled
|
|
1523
|
+
// rejection になっていた (同ファイルの setModel / setPermissionMode / setMaxThinkingTokens は
|
|
1524
|
+
// すべて .catch() 済みで、interrupt だけが非対称だった)。
|
|
1525
|
+
// さらに重要なのは「interrupt が届いたか確認する前に _busy=false にして drain する」点で、
|
|
1526
|
+
// control request が失敗すると SDK 側のターンは走り続けているのにこちらは「終わった」と
|
|
1527
|
+
// 判断し、次の pending を同じ InputQueue へ push してしまう (実行中ターンへの割り込み)。
|
|
1528
|
+
// reject 時は AbortController で generator ごと畳む (finally が pending を resume 再起動で
|
|
1529
|
+
// 拾い直すため、取りこぼしにはならない) 最終手段へフォールバックする。
|
|
1421
1530
|
try {
|
|
1422
|
-
this._residentQuery.interrupt()
|
|
1423
|
-
|
|
1424
|
-
|
|
1531
|
+
const p = this._residentQuery.interrupt()
|
|
1532
|
+
if (p && typeof p.catch === "function") {
|
|
1533
|
+
p.catch((err) => {
|
|
1534
|
+
this.logger?.warn(
|
|
1535
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
1536
|
+
"resident interrupt() rejected; falling back to hard abort",
|
|
1537
|
+
)
|
|
1538
|
+
this._hardAbortResident()
|
|
1539
|
+
})
|
|
1540
|
+
}
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
this.logger?.warn(
|
|
1543
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
1544
|
+
"resident interrupt() threw; falling back to hard abort",
|
|
1545
|
+
)
|
|
1546
|
+
this._hardAbortResident()
|
|
1425
1547
|
}
|
|
1426
1548
|
// SDK-P0-3 (2026-07-10): 中断も「このターンの終端」なので settled を立てる。
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
// (
|
|
1549
|
+
// SDK-P0-4: 中断されたターンも session_state_changed:idle を流しうる。drain が同期で
|
|
1550
|
+
// 次ターンを開始するため、その遅延 idle が新ターンを撃ち抜かないようガードを張る
|
|
1551
|
+
// (旧コメントは「_turnSettled か _turnProgressed のどちらかで必ず無視される」と断言して
|
|
1552
|
+
// いたが、中断ターンの in-flight イベントが _turnProgressed を立ててしまうため成立して
|
|
1553
|
+
// いなかった)。
|
|
1430
1554
|
this._turnSettled = true
|
|
1431
1555
|
this._busy = false
|
|
1556
|
+
this._armStaleIdleGuard()
|
|
1432
1557
|
// 孤児カード防止 (2026-07-10 監査): 中断したターンに紐づく未応答 permission は
|
|
1433
1558
|
// もう誰も応答しない。deny で canUseTool の Promise を解放し、browser のカードも
|
|
1434
1559
|
// 撤去する。これが無いと (a) 画面に承認カードが残り続け (b) _permissionResolvers に
|
|
@@ -1497,6 +1622,19 @@ class ClaudeStreamSession {
|
|
|
1497
1622
|
}
|
|
1498
1623
|
}
|
|
1499
1624
|
|
|
1625
|
+
/** SDK-P0-4 (2026-07-13): 常駐 query の最終手段の強制終了。interrupt() (control request) が
|
|
1626
|
+
* 失敗・無応答のときと close() から呼ぶ。AbortController を落とすと generator が終了し、
|
|
1627
|
+
* _runResidentQuery の finally が pending を resume 付きで再起動して取りこぼしを防ぐ。 */
|
|
1628
|
+
_hardAbortResident() {
|
|
1629
|
+
const ac = this._residentAbortController
|
|
1630
|
+
if (!ac || ac.signal?.aborted) return
|
|
1631
|
+
try {
|
|
1632
|
+
ac.abort()
|
|
1633
|
+
} catch {
|
|
1634
|
+
/* ignore */
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1500
1638
|
/** このセッションの常駐 query 内で MCP サーバーを再接続する (動作中チャットへ波及)。
|
|
1501
1639
|
* 常駐 query が無い (per-message セッション) 場合は false。per-message は毎ターン
|
|
1502
1640
|
* 新プロセスで MCP を張り直すため、次メッセージで自動的に再接続される。
|
|
@@ -1530,6 +1668,15 @@ class ClaudeStreamSession {
|
|
|
1530
1668
|
// 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
|
|
1531
1669
|
if (this._inputQueue) this._inputQueue.close()
|
|
1532
1670
|
this.abortTurn()
|
|
1671
|
+
// SDK-P0-4 (2026-07-13): close は「もう使わない」宣言なので、常駐 query は最終手段で
|
|
1672
|
+
// 確実に畳む。inputQueue.close() + interrupt() だけだと、CLI が control request に
|
|
1673
|
+
// 応答しない/ハングしたときに generator が終わらず、SIGTERM 受信時 (shutdown) に子プロセスが
|
|
1674
|
+
// 残留する。_closed=true 済みなので finally の pending 再起動経路には入らない。
|
|
1675
|
+
this._hardAbortResident()
|
|
1676
|
+
if (this._staleIdleTimer) {
|
|
1677
|
+
clearTimeout(this._staleIdleTimer)
|
|
1678
|
+
this._staleIdleTimer = null
|
|
1679
|
+
}
|
|
1533
1680
|
if (this._idleTimer) {
|
|
1534
1681
|
clearTimeout(this._idleTimer)
|
|
1535
1682
|
this._idleTimer = null
|
|
@@ -1597,6 +1744,12 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1597
1744
|
if (session.sessionId && this._liveBySession.get(session.sessionId) === session) {
|
|
1598
1745
|
this._liveBySession.delete(session.sessionId)
|
|
1599
1746
|
}
|
|
1747
|
+
// SDK-P0-4 (2026-07-13): session_id 回転前に登録した旧キーも外す (現 sessionId しか見ないと
|
|
1748
|
+
// 旧 id → 死んだ session のエントリが残り、attach が孤児を引き当てる)。
|
|
1749
|
+
const prevKey = session._liveKey
|
|
1750
|
+
if (prevKey && this._liveBySession.get(prevKey) === session) {
|
|
1751
|
+
this._liveBySession.delete(prevKey)
|
|
1752
|
+
}
|
|
1600
1753
|
}
|
|
1601
1754
|
|
|
1602
1755
|
/**
|
|
@@ -1736,7 +1889,20 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1736
1889
|
event,
|
|
1737
1890
|
})
|
|
1738
1891
|
// session_id 確定後は索引に登録 (冪等)。再接続時の再アタッチに使う。
|
|
1739
|
-
|
|
1892
|
+
// SDK-P0-4 (2026-07-13): /clear や compaction で session_id が回転すると、旧 id →
|
|
1893
|
+
// 生存 session のエントリが Map に永久残留し、attach(resume_session_id=旧 id) が
|
|
1894
|
+
// 意図しない live session を引き当てる (+ Map リーク)。登録済みキーを覚えておき、
|
|
1895
|
+
// 回転を検知したら旧キーを外してから張り替える。
|
|
1896
|
+
if (session.sessionId) {
|
|
1897
|
+
const prevKey = session._liveKey
|
|
1898
|
+
if (prevKey && prevKey !== session.sessionId) {
|
|
1899
|
+
if (this._liveBySession.get(prevKey) === session) {
|
|
1900
|
+
this._liveBySession.delete(prevKey)
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
session._liveKey = session.sessionId
|
|
1904
|
+
this._liveBySession.set(session.sessionId, session)
|
|
1905
|
+
}
|
|
1740
1906
|
},
|
|
1741
1907
|
onPermission: ({ tool_name, input, request_id }) => {
|
|
1742
1908
|
// 多端末共有: session_id を載せて backend が session group へ broadcast できるように
|
package/src/hub-bundle.mjs
CHANGED
|
@@ -300,7 +300,7 @@ function runOnce(argv, { logger } = {}) {
|
|
|
300
300
|
* (Cockpit 用途では install.sh が事前に claude をインストールしている
|
|
301
301
|
* 想定だが、独自運用環境への配慮で fail-soft にする)
|
|
302
302
|
*/
|
|
303
|
-
export async function applyMcpServers(bundle, { logger } = {}) {
|
|
303
|
+
export async function applyMcpServers(bundle, { agentId, agentToken, logger } = {}) {
|
|
304
304
|
const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
|
|
305
305
|
if (servers.length === 0) return { added: [], skipped: [] }
|
|
306
306
|
|
|
@@ -328,9 +328,16 @@ export async function applyMcpServers(bundle, { logger } = {}) {
|
|
|
328
328
|
transport,
|
|
329
329
|
"--scope",
|
|
330
330
|
scope,
|
|
331
|
-
s.name,
|
|
332
|
-
s.url,
|
|
333
331
|
]
|
|
332
|
+
// 越境制御 (2026-07): 一般向け `hub` MCP には、このホストの資格
|
|
333
|
+
// `X-Cockpit-Agent: <agentId>:<agentToken>` をヘッダで付与する。Hub backend は
|
|
334
|
+
// これを検証してホストのテナントを解決し、`hub` の acting tenant をそのテナントに
|
|
335
|
+
// **固定**する(クライアントホストは自テナントの外を触れない)。`hub_staff`(社内
|
|
336
|
+
// 横断)や外部 MCP には付けない。
|
|
337
|
+
if (s.name === "hub" && agentId && agentToken) {
|
|
338
|
+
argv.push("--header", `X-Cockpit-Agent: ${agentId}:${agentToken}`)
|
|
339
|
+
}
|
|
340
|
+
argv.push(s.name, s.url)
|
|
334
341
|
try {
|
|
335
342
|
await runOnce(argv, { logger })
|
|
336
343
|
added.push(s.name)
|
|
@@ -428,7 +435,7 @@ export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImp
|
|
|
428
435
|
try {
|
|
429
436
|
mcp = cliKind === "codex"
|
|
430
437
|
? await applyCodexMcpServers(bundle, { logger })
|
|
431
|
-
: await applyMcpServers(bundle, { logger })
|
|
438
|
+
: await applyMcpServers(bundle, { agentId, agentToken, logger })
|
|
432
439
|
} catch (err) {
|
|
433
440
|
logger?.warn({ err: err.message }, "applyMcpServers failed (continuing)")
|
|
434
441
|
}
|
package/src/main.mjs
CHANGED
|
@@ -24,12 +24,7 @@ import { extractLastAssistantText, pickContextText } from "./extract-paragraph.m
|
|
|
24
24
|
import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
|
|
25
25
|
import { WsClient } from "./ws-client.mjs"
|
|
26
26
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
27
|
-
import { ClaudeStreamBridge
|
|
28
|
-
import {
|
|
29
|
-
buildCapabilityReport,
|
|
30
|
-
readCodexReport,
|
|
31
|
-
readSdkVersion,
|
|
32
|
-
} from "./model-capability-report.mjs"
|
|
27
|
+
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
33
28
|
import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
|
|
34
29
|
import { UploadManager } from "./claude-upload.mjs"
|
|
35
30
|
import { requestSelfUninstall } from "./service-install.mjs"
|
|
@@ -1064,44 +1059,12 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1064
1059
|
if (claudeBridge) {
|
|
1065
1060
|
;(async () => {
|
|
1066
1061
|
const delaysMs = [0, 3000, 15000]
|
|
1067
|
-
let claudeOk = false
|
|
1068
1062
|
for (const d of delaysMs) {
|
|
1069
1063
|
if (d) await new Promise((r) => setTimeout(r, d))
|
|
1070
1064
|
const n = await claudeBridge.refreshModelCapabilities()
|
|
1071
|
-
if (n)
|
|
1072
|
-
claudeOk = true
|
|
1073
|
-
break // 1 件でも載れば成功
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
if (!claudeOk) {
|
|
1077
|
-
logger.warn({}, "model capability を起動フェーズで取得できず (browser effort へフォールバック)")
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
// 第2段 (2026-07-11): 取得した能力を Hub へ報告する。backend が is_staff ゲート+
|
|
1081
|
-
// バージョン単調ラチェットでモデルマスタの能力列へ反映する(詳細は
|
|
1082
|
-
// apps/ai_models/capability_sync.py)。claude はレジストリ由来、codex は codex CLI が
|
|
1083
|
-
// model/list をキャッシュした ~/.codex/models_cache.json を読む(案A、ゼロ spawn)。
|
|
1084
|
-
// 送信は fire-and-forget。backend が古い版/非staffなら黙って無視するので害はない。
|
|
1085
|
-
try {
|
|
1086
|
-
const codex = await readCodexReport().catch(() => null)
|
|
1087
|
-
const report = buildCapabilityReport({
|
|
1088
|
-
claudeModels: claudeOk ? getReportModels() : [],
|
|
1089
|
-
sdkVersion: readSdkVersion(),
|
|
1090
|
-
codex,
|
|
1091
|
-
})
|
|
1092
|
-
if (report) {
|
|
1093
|
-
client.send(report)
|
|
1094
|
-
logger.info(
|
|
1095
|
-
{
|
|
1096
|
-
claude: report.claude?.models?.length ?? 0,
|
|
1097
|
-
codex: report.codex?.models?.length ?? 0,
|
|
1098
|
-
},
|
|
1099
|
-
"model capability report を Hub へ送信",
|
|
1100
|
-
)
|
|
1101
|
-
}
|
|
1102
|
-
} catch (err) {
|
|
1103
|
-
logger.warn({ err: err?.message }, "model capability report 送信に失敗")
|
|
1065
|
+
if (n) return // 1 件でも載れば成功
|
|
1104
1066
|
}
|
|
1067
|
+
logger.warn({}, "model capability を起動フェーズで取得できず (browser effort へフォールバック)")
|
|
1105
1068
|
})().catch((err) =>
|
|
1106
1069
|
logger.warn({ err: err?.message }, "model capability 取得ループが例外終了"),
|
|
1107
1070
|
)
|
|
@@ -4113,6 +4076,12 @@ async function dispatch(msg, ctx) {
|
|
|
4113
4076
|
type: "claude.history.response",
|
|
4114
4077
|
stream_id,
|
|
4115
4078
|
session_id,
|
|
4079
|
+
// SDK-P0-4 (2026-07-13): 要求時の cwd をエコーバックする。claude.sessions.response は
|
|
4080
|
+
// 元から cwd を載せており frontend が cwd 照合で「別ワークスペース宛の遅延応答」を
|
|
4081
|
+
// 捨てられるのに、history.response だけ cwd が無く照合材料が存在しなかった。
|
|
4082
|
+
// その結果、無条件 hydrate → session_id 上書き → 購読スワップ → localStorage 汚染の
|
|
4083
|
+
// 経路が残っていた (sessionScope.ts が stream_id 主軸で守るのは relay イベントのみ)。
|
|
4084
|
+
cwd,
|
|
4116
4085
|
events: result.events,
|
|
4117
4086
|
total_lines: result.total_lines,
|
|
4118
4087
|
truncated: result.truncated,
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* モデル能力報告の組み立て(第2段, 2026-07-11)。
|
|
3
|
-
*
|
|
4
|
-
* hub-agent が上流(Claude Agent SDK / codex)から取得したモデル能力を、Hub backend の
|
|
5
|
-
* `model.capabilities.report` メッセージ形に整形する。backend は
|
|
6
|
-
* apply_capability_report() でバージョン単調ラチェットにより AIModelConfig の能力列へ反映する。
|
|
7
|
-
*
|
|
8
|
-
* - Claude: SDK `supportedModels()` 由来の能力レジストリ(getReportModels)+ SDK バージョン。
|
|
9
|
-
* - Codex: codex CLI 自身が `model/list` 応答をキャッシュした `~/.codex/models_cache.json` を
|
|
10
|
-
* 読む(案A)。起動ごとに codex app-server を spawn せずに済み、ラチェットに必要な
|
|
11
|
-
* client_version / fetched_at / etag / supported_reasoning_levels を全て持つ。
|
|
12
|
-
* ※ runtime の判定に cache を使うわけではない(それは第1段で直した context% の轍)。
|
|
13
|
-
* ここは「一度きりの報告」の一次データとして codex 自身の materialized view を読むだけ。
|
|
14
|
-
*/
|
|
15
|
-
import { promises as fs, readFileSync } from "node:fs"
|
|
16
|
-
import os from "node:os"
|
|
17
|
-
import path from "node:path"
|
|
18
|
-
import { createRequire } from "node:module"
|
|
19
|
-
|
|
20
|
-
/** インストール済み @anthropic-ai/claude-agent-sdk の version を読む。失敗時 null。
|
|
21
|
-
*
|
|
22
|
-
* SDK は `exports` フィールドで `./package.json` サブパスをブロックするため、
|
|
23
|
-
* `require("@anthropic-ai/claude-agent-sdk/package.json")` は
|
|
24
|
-
* ERR_PACKAGE_PATH_NOT_EXPORTED で失敗する。代わりにメインエントリを解決して
|
|
25
|
-
* そのパッケージルート(= dirname)の package.json を fs で直読みする。 */
|
|
26
|
-
export function readSdkVersion() {
|
|
27
|
-
try {
|
|
28
|
-
const require = createRequire(import.meta.url)
|
|
29
|
-
// "." export → 例: .../@anthropic-ai/claude-agent-sdk/sdk.mjs(パッケージルート直下)
|
|
30
|
-
const entry = require.resolve("@anthropic-ai/claude-agent-sdk")
|
|
31
|
-
// sdk.mjs はパッケージルートにあるため dirname がパッケージルート。
|
|
32
|
-
const pkgPath = path.join(path.dirname(entry), "package.json")
|
|
33
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
|
|
34
|
-
return typeof pkg?.version === "string" && pkg.version ? pkg.version : null
|
|
35
|
-
} catch {
|
|
36
|
-
return null
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function codexModelsCachePath() {
|
|
41
|
-
const dir = process.env.CODEX_DIR || path.join(os.homedir(), ".codex")
|
|
42
|
-
return process.env.HUB_CODEX_MODELS_CACHE || path.join(dir, "models_cache.json")
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* codex の models_cache.json を読み、報告ブロックへ整形する。失敗/未存在は null。
|
|
47
|
-
*
|
|
48
|
-
* `supported_reasoning_levels`([{effort, description}, ...])から effort 対応を導出:
|
|
49
|
-
* - supports_effort = low/medium/high 等が 1 つでもあれば true(reasoning あり)
|
|
50
|
-
* - supports_xhigh_effort = effort に "xhigh" を含むか
|
|
51
|
-
* `visibility === "hide"` のモデルはピッカー非表示だが、能力自体は正しいので報告に含める
|
|
52
|
-
* (backend は「マスタに存在する行」しか触らないため、hide モデルが紛れても実害なし)。
|
|
53
|
-
*/
|
|
54
|
-
export async function readCodexReport(cachePath = codexModelsCachePath()) {
|
|
55
|
-
let text
|
|
56
|
-
try {
|
|
57
|
-
text = await fs.readFile(cachePath, "utf8")
|
|
58
|
-
} catch {
|
|
59
|
-
return null
|
|
60
|
-
}
|
|
61
|
-
let j
|
|
62
|
-
try {
|
|
63
|
-
j = JSON.parse(text)
|
|
64
|
-
} catch {
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
67
|
-
const version = j?.client_version
|
|
68
|
-
if (typeof version !== "string" || !version) return null
|
|
69
|
-
const rawModels = Array.isArray(j?.models) ? j.models : []
|
|
70
|
-
const models = []
|
|
71
|
-
for (const m of rawModels) {
|
|
72
|
-
const name = m?.slug
|
|
73
|
-
if (typeof name !== "string" || !name) continue
|
|
74
|
-
const levels = Array.isArray(m.supported_reasoning_levels)
|
|
75
|
-
? m.supported_reasoning_levels
|
|
76
|
-
: []
|
|
77
|
-
const efforts = levels
|
|
78
|
-
.map((x) => (x && typeof x.effort === "string" ? x.effort : null))
|
|
79
|
-
.filter(Boolean)
|
|
80
|
-
models.push({
|
|
81
|
-
model_name: name,
|
|
82
|
-
supports_effort: efforts.length > 0,
|
|
83
|
-
supports_xhigh_effort: efforts.includes("xhigh") || efforts.includes("max"),
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
return {
|
|
87
|
-
version,
|
|
88
|
-
fetched_at: typeof j?.fetched_at === "string" ? j.fetched_at : undefined,
|
|
89
|
-
etag: typeof j?.etag === "string" ? j.etag : undefined,
|
|
90
|
-
models,
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* 報告メッセージを組み立てる。claude / codex は取得できたものだけ含める。
|
|
96
|
-
* どちらも空なら null を返す(送る意味がない)。
|
|
97
|
-
*
|
|
98
|
-
* @param {object} args
|
|
99
|
-
* @param {Array} args.claudeModels getReportModels() の戻り値(能力レジストリ由来)
|
|
100
|
-
* @param {string|null} args.sdkVersion readSdkVersion()
|
|
101
|
-
* @param {object|null} args.codex readCodexReport() の戻り値
|
|
102
|
-
*/
|
|
103
|
-
export function buildCapabilityReport({ claudeModels, sdkVersion, codex }) {
|
|
104
|
-
const msg = { type: "model.capabilities.report" }
|
|
105
|
-
if (sdkVersion && Array.isArray(claudeModels) && claudeModels.length > 0) {
|
|
106
|
-
msg.claude = { version: sdkVersion, models: claudeModels }
|
|
107
|
-
}
|
|
108
|
-
if (codex && codex.version && Array.isArray(codex.models) && codex.models.length > 0) {
|
|
109
|
-
msg.codex = {
|
|
110
|
-
version: codex.version,
|
|
111
|
-
models: codex.models,
|
|
112
|
-
}
|
|
113
|
-
if (codex.fetched_at) msg.codex.fetched_at = codex.fetched_at
|
|
114
|
-
if (codex.etag) msg.codex.etag = codex.etag
|
|
115
|
-
}
|
|
116
|
-
if (!msg.claude && !msg.codex) return null
|
|
117
|
-
return msg
|
|
118
|
-
}
|