@cocorograph/hub-agent 0.7.16 → 0.7.17

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.16",
3
+ "version": "0.7.17",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -91,6 +91,7 @@ import {
91
91
  getSessionUsages,
92
92
  getUsage,
93
93
  recordChatRateLimit,
94
+ turnActiveForCwd,
94
95
  } from "./usage.mjs"
95
96
  import {
96
97
  clearChatSignal,
@@ -1298,6 +1299,21 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1298
1299
  stable,
1299
1300
  now: nowMs,
1300
1301
  })
1302
+
1303
+ // turn_active: ターンが現在進行中か (true=生成/ツール実行中 / false=待機中 / null=不明)。
1304
+ // CockpitStatusDot の青/赤の権威ソース。サイドバーのドットと送信/停止ボタンを同じ真実で
1305
+ // 同期させ、Esc 中断後の青固着 (PROMPT_STALE_SHORT_MS=60s) を解消する。
1306
+ // - チャットモード: SDK の rate_limit_event/assistant が最も新鮮なので chat.status を採用
1307
+ // - TUI モード: jsonl 末尾 assistant.stop_reason から判定 (turnActiveForCwd)
1308
+ let turnActive = null
1309
+ if (chat?.status === "processing") {
1310
+ turnActive = true
1311
+ } else if (chat?.status === "waiting" || chat?.status === "idle") {
1312
+ turnActive = false
1313
+ } else if (s.cwd) {
1314
+ turnActive = await turnActiveForCwd(s.cwd)
1315
+ }
1316
+
1301
1317
  const prev = lastByName.get(s.session_name)
1302
1318
  if (
1303
1319
  !prev ||
@@ -1307,7 +1323,8 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1307
1323
  prev.stable !== stable ||
1308
1324
  prev.proc_busy !== procBusy ||
1309
1325
  prev.child_busy !== childBusy ||
1310
- prev.stalled !== stalled
1326
+ prev.stalled !== stalled ||
1327
+ prev.turn_active !== turnActive
1311
1328
  ) {
1312
1329
  lastByName.set(s.session_name, {
1313
1330
  status,
@@ -1317,6 +1334,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1317
1334
  proc_busy: procBusy,
1318
1335
  child_busy: childBusy,
1319
1336
  stalled,
1337
+ turn_active: turnActive,
1320
1338
  })
1321
1339
  // 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
1322
1340
  // 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
@@ -1333,6 +1351,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1333
1351
  armed,
1334
1352
  stable,
1335
1353
  context_pct: contextPct,
1354
+ turn_active: turnActive,
1336
1355
  },
1337
1356
  "session.state push",
1338
1357
  )
@@ -1349,6 +1368,9 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1349
1368
  // 出力残り火 (proc_busy 残存) でアイドルなのにキューへ逃がす誤キュー (症状A) を断つために使う。
1350
1369
  child_busy: childBusy,
1351
1370
  stalled,
1371
+ // turn_active: ステータスドット 4 状態統一の権威ソース。null は旧クライアント互換
1372
+ // (frontend は欠落時に旧 prompt_submit/stale 降格ロジックへフォールバック)。
1373
+ turn_active: turnActive,
1352
1374
  })
1353
1375
  }
1354
1376
  // 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
package/src/usage.mjs CHANGED
@@ -657,6 +657,142 @@ export function _resetJsonlSessionUuids() {
657
657
  _jsonlSessionUuidByCwd.clear()
658
658
  }
659
659
 
660
+ // ターン完了を示す Anthropic API `stop_reason`。これ以外 (代表的には `tool_use`)
661
+ // は「ターン継続中 = 生成 / ツール処理中」とみなす。
662
+ const TURN_END_STOP_REASONS = new Set([
663
+ "end_turn",
664
+ "stop_sequence",
665
+ "max_tokens",
666
+ ])
667
+
668
+ /**
669
+ * jsonl の末尾を順走査してターンが現在 active かを返す。
670
+ *
671
+ * ステートマシン:
672
+ * - `assistant` 行を見たら `stop_reason` でターン状態を更新する
673
+ * - end_turn / stop_sequence / max_tokens → 完了 (active=false)
674
+ * - tool_use (or それ以外) → 継続 (active=true)
675
+ * - `user` 行を見たとき、それが「実ユーザー入力」(tool_result でない) かつ
676
+ * 直前ターンが完了済みだったら、新ターンが開始されたので active=true
677
+ * - その他の型 (attachment / mode / hook_* / file-history-snapshot 等) は無視
678
+ *
679
+ * 初期値は「ターン完了状態」(lastTurnEnded=true)。空 jsonl や応答未着 のセッション
680
+ * は active=false でフォールバックする。
681
+ *
682
+ * @param {string} text jsonl の (末尾) テキスト
683
+ * @returns {boolean}
684
+ */
685
+ export function scanTurnActive(text) {
686
+ if (!text) return false
687
+ let active = false
688
+ let lastTurnEnded = true
689
+ const lines = text.split("\n")
690
+ for (const line of lines) {
691
+ if (!line) continue
692
+ let d
693
+ try {
694
+ d = JSON.parse(line)
695
+ } catch {
696
+ continue
697
+ }
698
+ if (d.type === "assistant") {
699
+ const stop = d.message?.stop_reason
700
+ if (stop && TURN_END_STOP_REASONS.has(stop)) {
701
+ active = false
702
+ lastTurnEnded = true
703
+ } else {
704
+ // tool_use / pause_turn / null / 未知 → ターン継続中扱い (安全側)
705
+ active = true
706
+ lastTurnEnded = false
707
+ }
708
+ } else if (d.type === "user") {
709
+ // 「実ユーザー入力」と「tool_result の自動 user ラッパー」を区別する。
710
+ // tool_result が含まれる user は前ターンの一部 (claude code 内部生成) なので、
711
+ // ターン状態を変えない。実ユーザー入力 (text/image/document) が直前ターン完了後に
712
+ // 来たら新ターン開始 → active=true。
713
+ const content = Array.isArray(d.message?.content) ? d.message.content : []
714
+ const isToolResult = content.some((c) => c?.type === "tool_result")
715
+ if (!isToolResult && lastTurnEnded) {
716
+ active = true
717
+ lastTurnEnded = false
718
+ }
719
+ }
720
+ }
721
+ return active
722
+ }
723
+
724
+ /** P5: turnActiveForCwd のメモ (newest fp + mtime + size をキー)。 */
725
+ const _turnActiveMemo = new Map()
726
+
727
+ /**
728
+ * 指定 cwd の Claude セッション jsonl から「現在ターンが進行中か」を返す。
729
+ *
730
+ * `CockpitStatusDot` の中央ドットの判定権威ソース。サイドバーのドット (非選択
731
+ * セッション) と送信/停止ボタン (選択セッション、`turnActive` ストリーム由来) を
732
+ * 同じ真実で同期させ、Esc 中断後の「青固着 60s」を解消する。
733
+ *
734
+ * 動作:
735
+ * 1. cwd → encoded dir → 最新 mtime の *.jsonl を選ぶ
736
+ * 2. 末尾 256KB を tail 読みし `scanTurnActive` でターン状態を判定
737
+ * 3. tail で結論が出ない巨大ツール結果ケースのみ全文 read にフォールバック
738
+ *
739
+ * @param {string} cwd
740
+ * @returns {Promise<boolean | null>} true=生成中 / false=待機中 / null=jsonl 不在
741
+ */
742
+ export async function turnActiveForCwd(cwd) {
743
+ if (!cwd) return null
744
+ const dirName = encodeCwdToDirName(cwd)
745
+ if (!dirName) return null
746
+ const dir = path.join(projectsDir(), dirName)
747
+ const files = await fs.readdir(dir).catch(() => null)
748
+ if (!files) return null
749
+
750
+ let newest = null
751
+ await Promise.all(
752
+ files.map(async (f) => {
753
+ if (!f.endsWith(".jsonl")) return
754
+ const fp = path.join(dir, f)
755
+ const st = await fs.stat(fp).catch(() => null)
756
+ if (!st) return
757
+ if (!newest || st.mtimeMs > newest.mtimeMs) {
758
+ newest = { fp, mtimeMs: st.mtimeMs, size: st.size }
759
+ }
760
+ }),
761
+ )
762
+ if (!newest) return null
763
+
764
+ const memo = _turnActiveMemo.get(newest.fp)
765
+ if (memo && memo.mtimeMs === newest.mtimeMs && memo.size === newest.size) {
766
+ return memo.result
767
+ }
768
+
769
+ // 256KB tail で多くの場合は決着がつく (1 ターン分の assistant + tool_use/result サイクル) 。
770
+ // tail 内に assistant 行が 0 件のみのケース (= 巨大 tool_result で先頭を tail から外れた)
771
+ // に備え、結果が「全行で active 状態が変化しない」場合は全文読みにフォールバックする。
772
+ const tail = await readTail(newest.fp, 256 * 1024)
773
+ let active = false
774
+ if (tail != null) {
775
+ active = scanTurnActive(tail)
776
+ }
777
+ // tail で active=false の場合のみ、巨大 tool_result で先頭の assistant 行が
778
+ // 落ちて誤って完了扱いになっていないか全文で確認する (高コストだが必要時のみ)。
779
+ if (active === false) {
780
+ const full = await readOrNull(newest.fp)
781
+ if (full != null) active = scanTurnActive(full)
782
+ }
783
+ _turnActiveMemo.set(newest.fp, {
784
+ mtimeMs: newest.mtimeMs,
785
+ size: newest.size,
786
+ result: active,
787
+ })
788
+ return active
789
+ }
790
+
791
+ /** テスト用: turnActive メモをクリアする。 */
792
+ export function _resetTurnActiveMemo() {
793
+ _turnActiveMemo.clear()
794
+ }
795
+
660
796
  /**
661
797
  * 5h / 7d 集計の UsageStats を返す。statusLine が無い環境では transcript
662
798
  * から推定する。