@cocorograph/hub-agent 0.7.19 → 0.7.20

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.19",
3
+ "version": "0.7.20",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -86,6 +86,31 @@ export function extractLastAssistantText(paneText) {
86
86
  return text
87
87
  }
88
88
 
89
+ /**
90
+ * permission/質問カードに出す「直前アシスタント説明」の採用優先順位を決める純関数。
91
+ *
92
+ * 症状4 根治: フック (bundle) 由来の context_text は PreToolUse 発火時点で jsonl が
93
+ * 前ターン止まり (フックがブロック中は claude が現ターンを commit しない=デッドロック)
94
+ * のため「前ターンの古い説明」を掴む取り違えを起こす。一方 hub-agent が tmux
95
+ * capture-pane で抜く liveContext は「いま描画中の現ターン本文」なので回答前に
96
+ * 信頼できる唯一のソース。よって **liveContext を最優先**し、フック由来は後方互換の
97
+ * フォールバックとしてのみ採用する (旧 bundle が context_text を送ってきても汚染しない)。
98
+ * 両方とも空/非文字列なら null (= 説明なしで安全縮退)。
99
+ *
100
+ * @param {unknown} hookContextText フック (bundle) が同梱した context_text (旧経路)
101
+ * @param {unknown} liveContext capture-pane から抽出した現ターン説明
102
+ * @returns {string|null}
103
+ */
104
+ export function pickContextText(hookContextText, liveContext) {
105
+ const live =
106
+ typeof liveContext === "string" && liveContext.trim() ? liveContext : null
107
+ const hook =
108
+ typeof hookContextText === "string" && hookContextText.trim()
109
+ ? hookContextText
110
+ : null
111
+ return live || hook || null
112
+ }
113
+
89
114
  export const __test = {
90
115
  _MAX_CHARS,
91
116
  _MIN_CHARS,
package/src/main.mjs CHANGED
@@ -20,7 +20,7 @@ import path from "node:path"
20
20
  import pino from "pino"
21
21
 
22
22
  import { detectPlatform, readConfig, writeConfig } from "./config.mjs"
23
- import { extractLastAssistantText } from "./extract-paragraph.mjs"
23
+ import { extractLastAssistantText, pickContextText } from "./extract-paragraph.mjs"
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"
@@ -92,6 +92,7 @@ import {
92
92
  contextWindowSize,
93
93
  getSessionUsages,
94
94
  getUsage,
95
+ isFreshUnboundBind,
95
96
  recordChatRateLimit,
96
97
  turnActiveForCwd,
97
98
  } from "./usage.mjs"
@@ -706,17 +707,18 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
706
707
  /* ignore */
707
708
  }
708
709
  }
709
- // ── 直前アシスタント説明の抽出 (T05xxx 根治) ────────────────────────────
710
+ // ── 直前アシスタント説明の抽出 (症状4 根治) ─────────────────────────────
710
711
  // PreToolUse フック発火時点で jsonl はまだ前ターン止まり (フックがブロック
711
- // している間 claude jsonl commit しない) のため、フック側の transcript
712
- // 抽出は前ターンの古い説明を掴む「取り違え」を起こす。
713
- // 代わりに hub-agent がここで tmux capture-pane を打ち、TUI のペイン上に
714
- // 既に描画されている「現ターンの assistant 本文段落 (⏺ プレフィックス)」を
715
- // 抜き出して context_text に注入する。フックが既に context_text を
716
- // 持っている場合 (旧経路) はそれを尊重し、空 / null のときだけ上書きする。
717
- // 抽出失敗時は null フォールバック (= 説明なし) で安全縮退。
712
+ // している間 claude は現ターンを jsonl commit しない=デッドロック構造)
713
+ // ため、フック側の transcript 抽出は前ターンの古い説明を掴む「取り違え」を
714
+ // 起こす。代わりに hub-agent がここで tmux capture-pane を打ち、TUI のペイン上
715
+ // に既に描画されている「現ターンの assistant 本文段落 (⏺ プレフィックス)」を
716
+ // 抜き出す。これが回答前に説明を届ける唯一の信頼ソースなので、フック由来の
717
+ // context_text の有無に関わらず **常に** capture-pane を試み、pickContextText
718
+ // liveContext を最優先する ( bundle が古い context_text を送ってきても汚染
719
+ // しない)。抽出失敗時は null フォールバック (= 説明なし) で安全縮退。
718
720
  let liveContext = null
719
- if (!context_text && cwd) {
721
+ if (cwd) {
720
722
  try {
721
723
  const sessions = await listTmuxSessions({ logger })
722
724
  const match = Array.isArray(sessions)
@@ -742,9 +744,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
742
744
  tool_name,
743
745
  input,
744
746
  // 質問/承認カードの直前アシスタント説明 (browser がカード上部に表示)。
745
- // 優先順位: (1) フックが既に同梱した context_text、(2) hub-agent が
746
- // tmux capture-pane で抽出した liveContext、(3) null。
747
- context_text: context_text || liveContext || null,
747
+ // 優先順位: (1) capture-pane で抽出した現ターンの liveContext、(2) フック由来
748
+ // context_text (後方互換フォールバック)、(3) null。pickContextText 参照。
749
+ context_text: pickContextText(context_text, liveContext),
748
750
  })
749
751
  },
750
752
  )
@@ -1366,6 +1368,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1366
1368
  turnActive = true
1367
1369
  } else if (chat?.status === "waiting" || chat?.status === "idle") {
1368
1370
  turnActive = false
1371
+ } else if (s.cwd && isFreshUnboundBind(boundSessions?.get(s.session_name))) {
1372
+ // 症状2b 根治: fresh bind プレースホルダの間は、別アクティビティ jsonl を mtime 最新で
1373
+ // 取り違える汚染を避けて turn_active を出さない (null=不明)。実セッション id へ adopt
1374
+ // されれば下の通常経路に乗る。青を出さない安全側にのみ倒すため固着青は生まない。
1375
+ turnActive = null
1369
1376
  } else if (s.cwd) {
1370
1377
  turnActive = await turnActiveForCwd(
1371
1378
  s.cwd,
@@ -1442,6 +1449,15 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1442
1449
  const liveNames = new Set(states.map((s) => s.session_name))
1443
1450
  for (const name of [...lastByName.keys()]) {
1444
1451
  if (liveNames.has(name)) continue
1452
+ // 終端を能動 broadcast (症状3 根治): tmux から消えた = crash/外部 kill/別経路
1453
+ // 終了。explicit kill は kill_session ハンドラが session.gone を出すが、それ以外
1454
+ // の消滅はここが唯一の検知点。frontend が states/lastEvent を忘れてドットを
1455
+ // 'down' に倒せるよう、ローカル Map を forget する前に通知する。
1456
+ try {
1457
+ client.send({ type: "session.gone", session_name: name })
1458
+ } catch {
1459
+ /* ignore */
1460
+ }
1445
1461
  invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
1446
1462
  stallTracker.forget(name) // StallTracker.byName
1447
1463
  readinessTracker?.forget?.(name) // ReadinessTracker.byName
@@ -1752,6 +1768,51 @@ export async function handleQueueFlush(msg, ctx) {
1752
1768
  })
1753
1769
  }
1754
1770
 
1771
+ /**
1772
+ * tmux.kill_session の処理 (症状3 根治)。要求された session を kill し、要求元へ
1773
+ * tmux.kill_session.result を返したうえで、kill できた各 session について
1774
+ * **session.gone を能動 broadcast** する。
1775
+ *
1776
+ * 不変条件: session の終端 (kill/crash/外部終了) は、同一ホストで実体を観測できる
1777
+ * agent が正本として能動 push する。これが無いと frontend は kill 時点の turn_active=true
1778
+ * を keep-last したままステータスドットが青で固着する。最後の 1 セッションを kill した
1779
+ * 場合 state loop の GC は states.length>0 ガードで発火しないため、explicit kill 経路の
1780
+ * ここから session.gone を出すのが確実 (GC は crash/外部 kill の補完)。
1781
+ *
1782
+ * テストから直接呼べるよう export。kill 実体 (killManySessions) は ctx で差し替え可能。
1783
+ */
1784
+ export async function handleKillSession(msg, ctx) {
1785
+ const killFn = ctx.killManySessions || killManySessions
1786
+ const names = Array.isArray(msg.session_names)
1787
+ ? msg.session_names
1788
+ : msg.session_name
1789
+ ? [msg.session_name]
1790
+ : []
1791
+ if (names.length === 0) {
1792
+ ctx.client.send({
1793
+ type: "tmux.kill_session.result",
1794
+ request_id: msg.request_id,
1795
+ killed: [],
1796
+ failed: [{ name: "", reason: "session_name(s) required" }],
1797
+ })
1798
+ return
1799
+ }
1800
+ const r = await killFn(names)
1801
+ ctx.client.send({
1802
+ type: "tmux.kill_session.result",
1803
+ request_id: msg.request_id,
1804
+ killed: r.killed,
1805
+ failed: r.failed,
1806
+ })
1807
+ for (const name of r.killed) {
1808
+ try {
1809
+ ctx.client.send({ type: "session.gone", session_name: name })
1810
+ } catch {
1811
+ /* ignore */
1812
+ }
1813
+ }
1814
+ }
1815
+
1755
1816
  /**
1756
1817
  * claude.tui.interrupt の処理 (確認付き中断 = Phase3)。生 ESC を tracked pty.data で best-effort
1757
1818
  * 送出する旧経路 (stream 欠落で無言ドロップ + ESC 到達でも止まったか未検証) を、agent が ESC を
@@ -3191,27 +3252,7 @@ async function dispatch(msg, ctx) {
3191
3252
  return
3192
3253
  }
3193
3254
  case "tmux.kill_session": {
3194
- const names = Array.isArray(msg.session_names)
3195
- ? msg.session_names
3196
- : msg.session_name
3197
- ? [msg.session_name]
3198
- : []
3199
- if (names.length === 0) {
3200
- ctx.client.send({
3201
- type: "tmux.kill_session.result",
3202
- request_id: msg.request_id,
3203
- killed: [],
3204
- failed: [{ name: "", reason: "session_name(s) required" }],
3205
- })
3206
- return
3207
- }
3208
- const r = await killManySessions(names)
3209
- ctx.client.send({
3210
- type: "tmux.kill_session.result",
3211
- request_id: msg.request_id,
3212
- killed: r.killed,
3213
- failed: r.failed,
3214
- })
3255
+ await handleKillSession(msg, ctx)
3215
3256
  return
3216
3257
  }
3217
3258
  case "skills.request": {
package/src/usage.mjs CHANGED
@@ -603,6 +603,28 @@ export function boundSessionId(boundSessions, name) {
603
603
  return typeof v === "string" && v && !v.startsWith("fresh:") ? v : null
604
604
  }
605
605
 
606
+ /**
607
+ * その tmux セッションが「fresh bind プレースホルダ (`fresh:<req>`)」の状態か判定する
608
+ * (症状2b 根治)。
609
+ *
610
+ * fresh bind は「新規セッションを開始する」意思表示で、まだ実セッション id へ adopt されて
611
+ * おらず、このペインが走らせている jsonl が確定していない。この状態で turn_active を
612
+ * `resolveTargetJsonl` の mtime 最新フォールバックから導くと、同一 cwd-encode dir に堆積した
613
+ * 別アクティビティ (headless `claude -p` / subagent / 並走セッション / 旧 jsonl) を「最新」と
614
+ * 取り違えて **偽の生成中 (青ドット) を出し、新規セッションで無操作なのに三点リーダーが固着** する。
615
+ *
616
+ * よって呼び出し側 (state loop) は fresh プレースホルダの間 turn_active を null (不明) に倒す。
617
+ * これは安全側 (青を出さない方向) にしか倒さないため、偽陽性の青固着を生むことはない。生成中の
618
+ * in-chat スピナーは frontend が turnOverride / session.event(prompt_submit) で別途駆動するため、
619
+ * fresh セッションの実生成中表示は保たれる (本判定はサイドバー/ヘッダーのドット権威のみに効く)。
620
+ *
621
+ * @param {string|null|undefined} boundVal `boundSessions.get(session_name)` の生値
622
+ * @returns {boolean}
623
+ */
624
+ export function isFreshUnboundBind(boundVal) {
625
+ return typeof boundVal === "string" && boundVal.startsWith("fresh:")
626
+ }
627
+
606
628
  /**
607
629
  * 指定 cwd の Claude セッション jsonl から context% (USED %) を算出する。
608
630
  *