@cocorograph/hub-agent 0.7.54 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.54",
3
+ "version": "0.7.56",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -271,6 +271,32 @@ function isSessionIdleEvent(msg) {
271
271
  )
272
272
  }
273
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
+
274
300
  /**
275
301
  * 1 stream に対応する Claude セッション (per-message query モデル)。
276
302
  */
@@ -404,18 +430,40 @@ class ClaudeStreamSession {
404
430
  this._turnSettled = false
405
431
  /** SDK-P0-3: 現ターンが 1 件でもストリームイベントを出したか。
406
432
  * 「前ターンの遅延 idle が、drain で始まったばかりの次ターンを誤終了させる」レースの
407
- * ガードに使う。generator のイベント順序は保証される (turn N の idle は turn N+1 の
408
- * 最初のイベントより必ず先に届く) ため、`idle 到着時に現ターンがまだ何も出していない`
409
- * = その idle は前ターンのもの、と判定できる。ターン開始時に false へ戻す。 */
433
+ * 一次ガード。generator のイベント順序保証から導けるのは **否定方向だけ** である点に注意:
434
+ * 「まだ 1 件も出ていない (=false) → この idle は前ターンのもの」は正しいが、逆の
435
+ * 「1 件出ている (=true) → この idle は現ターンのもの」は導けない (前ターンの遅延イベントでも
436
+ * true になるため)。その穴は SDK-P0-4 の _staleIdleExpected / _turnRunningSeen で塞ぐ。
437
+ * ターン開始時に false へ戻す。 */
410
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
411
453
  /** detach 済みフラグ (新規ターン受付停止) */
412
454
  this._closed = false
413
455
  /** 実行中ターンの完走を待ってからクローズする予約フラグ (graceful detach 用)。
414
456
  * 端末スリープ / ネット断で browser が切れても、明示的な中断 (interrupt) が
415
457
  * 無い限りターンを落とさず最後まで走らせるために使う。 */
416
458
  this._reapAfterTurn = false
417
- /** 現在ターンの AbortController (interrupt 用) */
459
+ /** 現在ターンの AbortController (per-message の interrupt 用) */
418
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
419
467
 
420
468
  /** jsonl ライブ追従 watcher (0.6.5)。外部 (tmux 等) の追記を拾う。 */
421
469
  this._watcher = null
@@ -477,8 +525,20 @@ class ClaudeStreamSession {
477
525
  )
478
526
  }
479
527
 
480
- /** 現在の 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 にする際のブロッカーだった)。 */
481
540
  _ensureWatch() {
541
+ if (this._residentEligible) return
482
542
  if (this._closed || !this.sessionId || !this.cwd) return
483
543
  if (this._watchedSessionId === this.sessionId && this._watcher) return
484
544
  // 旧 watcher を畳む
@@ -1203,6 +1263,34 @@ class ClaudeStreamSession {
1203
1263
  this._busy = true
1204
1264
  this._turnSettled = false
1205
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
+ }
1206
1294
  }
1207
1295
 
1208
1296
  /** SDK-P0-3 (2026-07-10): resident の「ターン終端」を 1 箇所に集約する (result / idle 共通)。
@@ -1216,6 +1304,11 @@ class ClaudeStreamSession {
1216
1304
  this._turnSettled = true
1217
1305
  denyPending?.("turn ended")
1218
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()
1219
1312
  // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
1220
1313
  // ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
1221
1314
  // async。for await ループ内からは fire-and-forget で呼ぶ (最初の await より前に
@@ -1271,11 +1364,16 @@ class ClaudeStreamSession {
1271
1364
  * 常駐では query stream が唯一のイベント源のため watcher は張らない (過去確定分は browser が
1272
1365
  * history.request で hydrate 済み。resume で query stream が過去行を replay しない前提)。 */
1273
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()
1274
1371
  const options = {
1275
1372
  cwd: this.cwd,
1276
1373
  env: siblingEnvFor(this.cwd),
1277
1374
  canUseTool: (toolName, input) => this._canUseTool(toolName, input),
1278
1375
  includePartialMessages: true,
1376
+ abortController: this._residentAbortController,
1279
1377
  stderr: (data) => this._stderrHandler(data),
1280
1378
  }
1281
1379
  if (this.model) options.model = this.model
@@ -1307,6 +1405,10 @@ class ClaudeStreamSession {
1307
1405
  }
1308
1406
  if (msg?.type === "result") {
1309
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()
1310
1412
  // ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
1311
1413
  this._settleResidentTurn("result", denyPending)
1312
1414
  } else if (isSessionIdleEvent(msg)) {
@@ -1315,13 +1417,18 @@ class ClaudeStreamSession {
1315
1417
  // _pendingMessages に退避されたまま (drain 契機の result が永久に来ないので)
1316
1418
  // 永久滞留していた ("resident busy, message queued" が出続ける / リロードで消える)。
1317
1419
  //
1318
- // ガード: 「前ターンの遅延 idle」で新ターンを誤終了させない。result で settle →
1319
- // drain → 次ターン開始 (_busy=true) の直後に、同じターンの idle が続けて届く
1320
- // (SDK は result → idle の順に流す) ため、素朴に _busy=false にすると新ターンを
1321
- // 撃ち抜き二重 drain / FIFO 崩れを起こす。generator のイベント順序は保証されるので
1322
- // 「現ターンがまだ 1 件もイベントを出していない (_turnProgressed=false)」なら
1323
- // その idle は前ターンのものと確定できる。
1324
- if (this._busy && this._turnProgressed) {
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)) {
1325
1432
  this._settleResidentTurn("idle", denyPending)
1326
1433
  // per-message の RC-4 と対称に、チャット信号 (サイドバードット) を
1327
1434
  // processing→waiting へ落とす。result 経由なら main.mjs が result を見て
@@ -1333,8 +1440,15 @@ class ClaudeStreamSession {
1333
1440
  }
1334
1441
  }
1335
1442
  } else {
1336
- // 現ターンが「進行した」証跡。init / assistant / stream_event / user、および
1337
- // session_state_changed の running / requires_action もここに含む。
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 もここに含む。
1338
1452
  this._turnProgressed = true
1339
1453
  }
1340
1454
  try {
@@ -1404,17 +1518,42 @@ class ClaudeStreamSession {
1404
1518
  // 常駐 query は interrupt() で現ターンのみ中断する (query は継続、次 input 受付継続)。
1405
1519
  if (this._residentQuery && typeof this._residentQuery.interrupt === "function") {
1406
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
+ // 拾い直すため、取りこぼしにはならない) 最終手段へフォールバックする。
1407
1530
  try {
1408
- this._residentQuery.interrupt()
1409
- } catch {
1410
- /* ignore */
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()
1411
1547
  }
1412
1548
  // SDK-P0-3 (2026-07-10): 中断も「このターンの終端」なので settled を立てる。
1413
- // interrupt() 後に SDK が session_state_changed:idle を流しても、_turnSettled=true
1414
- // (pending 無しで drain が早期 return するケース) か _turnProgressed=false
1415
- // (drain が次ターンを開始したケース) のどちらかで必ず無視される。
1549
+ // SDK-P0-4: 中断されたターンも session_state_changed:idle を流しうる。drain が同期で
1550
+ // 次ターンを開始するため、その遅延 idle が新ターンを撃ち抜かないようガードを張る
1551
+ // (旧コメントは「_turnSettled か _turnProgressed のどちらかで必ず無視される」と断言して
1552
+ // いたが、中断ターンの in-flight イベントが _turnProgressed を立ててしまうため成立して
1553
+ // いなかった)。
1416
1554
  this._turnSettled = true
1417
1555
  this._busy = false
1556
+ this._armStaleIdleGuard()
1418
1557
  // 孤児カード防止 (2026-07-10 監査): 中断したターンに紐づく未応答 permission は
1419
1558
  // もう誰も応答しない。deny で canUseTool の Promise を解放し、browser のカードも
1420
1559
  // 撤去する。これが無いと (a) 画面に承認カードが残り続け (b) _permissionResolvers に
@@ -1483,6 +1622,19 @@ class ClaudeStreamSession {
1483
1622
  }
1484
1623
  }
1485
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
+
1486
1638
  /** このセッションの常駐 query 内で MCP サーバーを再接続する (動作中チャットへ波及)。
1487
1639
  * 常駐 query が無い (per-message セッション) 場合は false。per-message は毎ターン
1488
1640
  * 新プロセスで MCP を張り直すため、次メッセージで自動的に再接続される。
@@ -1516,6 +1668,15 @@ class ClaudeStreamSession {
1516
1668
  // 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
1517
1669
  if (this._inputQueue) this._inputQueue.close()
1518
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
+ }
1519
1680
  if (this._idleTimer) {
1520
1681
  clearTimeout(this._idleTimer)
1521
1682
  this._idleTimer = null
@@ -1583,6 +1744,12 @@ export class ClaudeStreamBridge extends EventEmitter {
1583
1744
  if (session.sessionId && this._liveBySession.get(session.sessionId) === session) {
1584
1745
  this._liveBySession.delete(session.sessionId)
1585
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
+ }
1586
1753
  }
1587
1754
 
1588
1755
  /**
@@ -1722,7 +1889,20 @@ export class ClaudeStreamBridge extends EventEmitter {
1722
1889
  event,
1723
1890
  })
1724
1891
  // session_id 確定後は索引に登録 (冪等)。再接続時の再アタッチに使う。
1725
- if (session.sessionId) this._liveBySession.set(session.sessionId, session)
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
+ }
1726
1906
  },
1727
1907
  onPermission: ({ tool_name, input, request_id }) => {
1728
1908
  // 多端末共有: session_id を載せて backend が session group へ broadcast できるように
@@ -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
@@ -4076,6 +4076,12 @@ async function dispatch(msg, ctx) {
4076
4076
  type: "claude.history.response",
4077
4077
  stream_id,
4078
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,
4079
4085
  events: result.events,
4080
4086
  total_lines: result.total_lines,
4081
4087
  truncated: result.truncated,