@cocorograph/hub-agent 0.8.9 → 0.8.11

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.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -664,7 +664,13 @@ function takeTextsFromLine(line, user, assistant) {
664
664
  return { user, assistant }
665
665
  }
666
666
 
667
- export async function extractLastTurnTexts(filePath) {
667
+ /**
668
+ * @param {string} filePath 対象 jsonl
669
+ * @param {{ isInjected?: (text: string) => Promise<boolean> | boolean }} [opts]
670
+ * 注入と判定した user ターンは、そのターンの assistant も含めて飛ばす。
671
+ */
672
+ export async function extractLastTurnTexts(filePath, opts = {}) {
673
+ const isInjected = typeof opts.isInjected === "function" ? opts.isInjected : null
668
674
  let handle
669
675
  try {
670
676
  handle = await open(filePath, "r")
@@ -716,11 +722,26 @@ export async function extractLastTurnTexts(filePath) {
716
722
  continue
717
723
  }
718
724
  const res = takeTextsFromLine(lineBuf.toString("utf-8"), user, assistant)
725
+ if (isInjected && res.user && !user) {
726
+ let injected = false
727
+ try {
728
+ injected = (await isInjected(res.user)) === true
729
+ } catch {
730
+ // 注入判定の障害で通常の依頼まで消さない。
731
+ }
732
+ if (injected) {
733
+ // 後方走査中の assistant はこの注入 user より後ろ、つまり同じ注入ターンの
734
+ // 応答候補。依頼だけでなく応答も捨てて、更に前の自分のターンを探す。
735
+ user = ""
736
+ assistant = ""
737
+ continue
738
+ }
739
+ }
719
740
  user = res.user
720
741
  assistant = res.assistant
721
- if (user && assistant) break
742
+ if (isInjected ? Boolean(user) : user && assistant) break
722
743
  }
723
- if (user && assistant) break
744
+ if (isInjected ? Boolean(user) : user && assistant) break
724
745
  if (skipped > LAST_TURN_MAX_SKIPPED_LINES) break
725
746
 
726
747
  if (carry && carry.length > LAST_TURN_MAX_LINE) {
@@ -523,11 +523,11 @@ export class ClaudeStreamSession {
523
523
  this._permissionResolvers = new Map()
524
524
  /** 現在ターン実行中か (多重 query 防止) */
525
525
  this._busy = false
526
- /** SDK-P0-3 (2026-07-10): 現ターンが既に終端処理済みか (result / idle / abort の
526
+ /** SDK-P0-3 (2026-07-10): 現ターンが既に終端処理済みか (idle / abort の
527
527
  * いずれか 1 回だけ _busy=false + drain させるための冪等ガード)。resident query は
528
- * 1 ターンにつき `result` と `session_state_changed:idle` の**両方**が流れうるため、
529
- * 素朴に両方で drain すると二重 drain (FIFO 崩れ) を起こす。ターン開始時
530
- * (_beginResidentTurn) に false へ戻す。 */
528
+ * `result` の後にもバックグラウンド処理が続き、最後に権威的な
529
+ * `session_state_changed:idle` が流れる。通常完了では idle だけが settle し、
530
+ * 明示 interrupt だけが先行して settled を立てる。ターン開始時に false へ戻す。 */
531
531
  this._turnSettled = false
532
532
  /** SDK-P0-3: 現ターンが 1 件でもストリームイベントを出したか。
533
533
  * 「前ターンの遅延 idle が、drain で始まったばかりの次ターンを誤終了させる」レースの
@@ -539,9 +539,9 @@ export class ClaudeStreamSession {
539
539
  this._turnProgressed = false
540
540
  /** SDK-P0-4 (2026-07-13): ターン連番。ログと帰属デバッグ用 (_beginResidentTurn で ++)。 */
541
541
  this._turnSeq = 0
542
- /** SDK-P0-4: 「直前ターンの idle がこれから 1 件届きうる」状態。result で settle したとき、
543
- * および abort (interrupt) でターンを畳んだときに arm する (SDK は result → idle の順で
544
- * 流し、interrupt 後にも中断ターンの idle を流すため)。arm 中に届いた idle は
542
+ /** SDK-P0-4: 「直前ターンの idle がこれから 1 件届きうる」状態。abort (interrupt) で
543
+ * ターンを先行して畳んだときに arm する (interrupt 後にも中断ターンの idle が流れるため)。
544
+ * 通常完了は idle 自身で畳むので arm しない。arm 中に届いた idle は
545
545
  * **前ターンのもの**として 1 件だけ握り潰し、現ターンを終端しない。
546
546
  * 解除条件: (a) idle を 1 件消費した (b) SDK の running が届いた (= 前ターンの全イベントは
547
547
  * 順序保証により既に通過済み) (c) 現ターンの result が届いた (d) STALE_IDLE_GUARD_MS 経過。 */
@@ -1131,9 +1131,10 @@ export class ClaudeStreamSession {
1131
1131
  this._busy = true
1132
1132
  this._abortController = new AbortController()
1133
1133
  let aborted = false
1134
- // RC-4: このターンで SDK の result イベントが届いたか。届かずに終わった
1135
- // (abort / 異常終了) ときだけ onTurnSettled でチャット信号を waiting へ落とす。
1134
+ // `session_state_changed:idle` がターン終了の正本。SDK が idle を流さず generator が
1135
+ // 閉じた旧/異常経路だけ合成 idle と onTurnSettled で waiting へ落とす。
1136
1136
  let resultDelivered = false
1137
+ let idleDelivered = false
1137
1138
 
1138
1139
  const options = {
1139
1140
  cwd: this.cwd,
@@ -1185,6 +1186,7 @@ export class ClaudeStreamSession {
1185
1186
  resultDelivered = true
1186
1187
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1187
1188
  }
1189
+ if (isSessionIdleEvent(msg)) idleDelivered = true
1188
1190
  try {
1189
1191
  this._emit(msg)
1190
1192
  } catch (err) {
@@ -1224,12 +1226,9 @@ export class ClaudeStreamSession {
1224
1226
  if (aborted) {
1225
1227
  this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
1226
1228
  }
1227
- // RC-4: result を届けずにターンが終わった (abort / 異常終了) 場合、チャット信号の
1228
- // status=processing が解除されず三点リーダー/ステータスドットが TTL まで固着する。
1229
- // ターン確定として onTurnSettled を呼び processing→waiting へ落とす。正常完了は
1230
- // result が既に waiting にしているので !resultDelivered のときだけ。_drainPending で
1231
- // 次ターン (processing) が始まる前に呼ぶこと (順序: ここ → reap/drain)。
1232
- if (!resultDelivered) {
1229
+ // RC-4: idle を届けずに generator が閉じた旧/異常経路では、チャット信号と browser の
1230
+ // 双方へ同じ終了境界を補う。_drainPending で次ターンを始める前に行うこと。
1231
+ if (!idleDelivered) {
1233
1232
  try {
1234
1233
  this.onTurnSettled?.()
1235
1234
  } catch {
@@ -1244,11 +1243,24 @@ export class ClaudeStreamSession {
1244
1243
  // フッターで表示する)。uuid を持たせるのは重複排除 (isDuplicateEvent) が result
1245
1244
  // 署名 (session_id + num_turns + duration_ms + cost) で判定するため — 合成 result
1246
1245
  // はこれらが毎回同値になり、2 回目以降の中断で「重複」と誤判定され捨てられるのを防ぐ。
1246
+ if (!resultDelivered) {
1247
+ try {
1248
+ this._emit({
1249
+ type: "result",
1250
+ subtype: aborted ? "aborted_by_user" : "turn_settled",
1251
+ uuid: randomUUID(),
1252
+ session_id: this.sessionId ?? undefined,
1253
+ timestamp: new Date().toISOString(),
1254
+ })
1255
+ } catch {
1256
+ /* ignore */
1257
+ }
1258
+ }
1247
1259
  try {
1248
1260
  this._emit({
1249
- type: "result",
1250
- subtype: aborted ? "aborted_by_user" : "turn_settled",
1251
- uuid: randomUUID(),
1261
+ type: "system",
1262
+ subtype: "session_state_changed",
1263
+ state: "idle",
1252
1264
  session_id: this.sessionId ?? undefined,
1253
1265
  timestamp: new Date().toISOString(),
1254
1266
  })
@@ -1618,10 +1630,10 @@ export class ClaudeStreamSession {
1618
1630
  }
1619
1631
  }
1620
1632
 
1621
- /** SDK-P0-3 (2026-07-10): resident の「ターン終端」を 1 箇所に集約する (result / idle 共通)。
1622
- * 同一ターンで 2 回以上呼ばれても最初の 1 回しか作用しない (_turnSettled ガード)。
1623
- * これにより `result` → `idle` の順で両方届く通常ケースでも drain は 1 回だけになる。
1624
- * @param {string} reason "result" | "idle" — ログ用
1633
+ /** resident の権威的な「ターン終端」を 1 箇所に集約する。
1634
+ * 通常完了は SDK の session_state_changed:idle だけが呼ぶ。result は最終結果の
1635
+ * flush であり、バックグラウンド処理を含むターン全体の終了境界ではない。
1636
+ * @param {string} reason "idle" — ログ用
1625
1637
  * @param {(msg: string) => void} [denyPending] 未解決 permission を閉じるクロージャ
1626
1638
  */
1627
1639
  _settleResidentTurn(reason, denyPending) {
@@ -1629,11 +1641,6 @@ export class ClaudeStreamSession {
1629
1641
  this._turnSettled = true
1630
1642
  denyPending?.("turn ended")
1631
1643
  this._busy = false
1632
- // SDK-P0-4: result で畳んだターンは、この後に自分の idle を必ず 1 件流す
1633
- // (sdk.d.ts: "'idle' fires after heldBackResult flushes … authoritative turn-over signal")。
1634
- // drain が同期で次ターンを開始するため、その idle が新ターンに誤帰属しないようガードを張る。
1635
- // idle 自身で畳んだ場合は既にその idle を消費済みなので張らない。
1636
- if (reason === "result") this._armStaleIdleGuard()
1637
1644
  // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
1638
1645
  // ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
1639
1646
  // async。for await ループ内からは fire-and-forget で呼ぶ (最初の await より前に
@@ -1649,7 +1656,7 @@ export class ClaudeStreamSession {
1649
1656
  /** 改修4 (A): ターン完了時に pending の先頭 1 件を InputQueue へ流す (ターンのシリアライズ)。
1650
1657
  * queue_state を更新して送信待ちチップを drain させる (frontend がバブル昇格する)。
1651
1658
  * ultracode (0.6.28): 次ターンの目標 ultracode 状態へ reconcile してから push する
1652
- * (await するため async 化。result ハンドラからは fire-and-forget で呼ばれる)。 */
1659
+ * (await するため async 化。idle ハンドラからは fire-and-forget で呼ばれる)。 */
1653
1660
  async _drainResidentPending() {
1654
1661
  if (this._closed) return
1655
1662
  if (this._pendingMessages.length === 0) {
@@ -1693,7 +1700,7 @@ export class ClaudeStreamSession {
1693
1700
 
1694
1701
  /** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
1695
1702
  * system/init は最初の 1 回のみ (毎ターン init = 文脈/キャッシュ温め直しを解消)。各ターンは
1696
- * 終端シグナル (result / session_state_changed:idle) で _busy=false にするが query は継続し
1703
+ * 権威的終端シグナル (session_state_changed:idle) で _busy=false にするが query は継続し
1697
1704
  * 次 input を待つ。close (inputQueue.close) で generator が終わり query が完走する。
1698
1705
  * SDK-P0-3 (2026-07-10): 終端を result だけに頼ると、SDK が idle でターンを終えたときに
1699
1706
  * _busy が固着し、以降の送信が pending へ永久滞留する (T06052)。
@@ -1749,6 +1756,7 @@ export class ClaudeStreamSession {
1749
1756
  const q = this.sdk.query({ prompt: this._inputQueue, options })
1750
1757
  this._residentQuery = q
1751
1758
  for await (const msg of q) {
1759
+ let suppressEmit = false
1752
1760
  if (
1753
1761
  msg?.type === "system" &&
1754
1762
  msg?.subtype === "init" &&
@@ -1758,12 +1766,12 @@ export class ClaudeStreamSession {
1758
1766
  }
1759
1767
  if (msg?.type === "result") {
1760
1768
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1761
- // SDK-P0-4: 現ターンの result が届いた = 前ターンの遅延 idle は順序保証により
1762
- // 既に通過済み (届いたなら消費済み / 届かないなら二度と来ない)。ガードを解除してから
1763
- // settle する (settle が result 用のガードを張り直す)。
1769
+ // result は結果 flush でありターン終了ではない。SDK の型定義が権威的な
1770
+ // turn-over signal とする session_state_changed:idle まで busy を維持し、
1771
+ // pending を streaming input へ流さない。
1772
+ // 現ターンの result が届いた時点で前ターンの遅延 idle は順序保証により
1773
+ // 既に通過済みなので、interrupt 由来のガードだけ解除できる。
1764
1774
  this._clearStaleIdleGuard()
1765
- // ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
1766
- this._settleResidentTurn("result", denyPending)
1767
1775
  } else if (isSessionIdleEvent(msg)) {
1768
1776
  // SDK-P0-3 (2026-07-10): result が届かずに idle でターンが終わるケースの救済。
1769
1777
  // これを見ていなかったため _busy が true で固着し、以降の送信が
@@ -1777,11 +1785,14 @@ export class ClaudeStreamSession {
1777
1785
  // 2) それ以外で _busy かつ (running を観測済み or 何かイベントを出した) → 現ターンの終端。
1778
1786
  if (this._staleIdleExpected) {
1779
1787
  this._clearStaleIdleGuard()
1788
+ // browser にも転送しない。前ターンの遅延 idle を新ターンの reducer が受けると、
1789
+ // 3点リーダーと送信ボタンだけが誤って idle になる。
1790
+ suppressEmit = true
1780
1791
  this.logger?.debug?.(
1781
1792
  { stream_id: this.stream_id, turn: this._turnSeq },
1782
1793
  "stale idle from previous turn ignored",
1783
1794
  )
1784
- } else if (this._busy && (this._turnRunningSeen || this._turnProgressed)) {
1795
+ } else if (this._busy) {
1785
1796
  this._settleResidentTurn("idle", denyPending)
1786
1797
  // per-message の RC-4 と対称に、チャット信号 (サイドバードット) を
1787
1798
  // processing→waiting へ落とす。result 経由なら main.mjs が result を見て
@@ -1804,13 +1815,15 @@ export class ClaudeStreamSession {
1804
1815
  // および session_state_changed の running / requires_action もここに含む。
1805
1816
  this._turnProgressed = true
1806
1817
  }
1807
- try {
1808
- this._emit(msg)
1809
- } catch (err) {
1810
- this.logger?.warn(
1811
- { err: err.message, stream_id: this.stream_id },
1812
- "onEvent callback threw",
1813
- )
1818
+ if (!suppressEmit) {
1819
+ try {
1820
+ this._emit(msg)
1821
+ } catch (err) {
1822
+ this.logger?.warn(
1823
+ { err: err.message, stream_id: this.stream_id },
1824
+ "onEvent callback threw",
1825
+ )
1826
+ }
1814
1827
  }
1815
1828
  }
1816
1829
  } catch (err) {
@@ -1949,6 +1962,19 @@ export class ClaudeStreamSession {
1949
1962
  } catch {
1950
1963
  /* ignore */
1951
1964
  }
1965
+ // 通常完了と同じ権威境界を browser へ即時提示する。SDK が後から流す
1966
+ // 中断ターン由来の実 idle は _staleIdleExpected で抑止する。
1967
+ try {
1968
+ this._emit({
1969
+ type: "system",
1970
+ subtype: "session_state_changed",
1971
+ state: "idle",
1972
+ session_id: this.sessionId ?? undefined,
1973
+ timestamp: new Date().toISOString(),
1974
+ })
1975
+ } catch {
1976
+ /* ignore */
1977
+ }
1952
1978
  }
1953
1979
  // T05699 残タスク #2-1 (2026-07-04): interrupt() は現ターンの result を発火しない
1954
1980
  // (query は継続、次 input 受付継続のみ)。そのため abort 時点で _pendingMessages に