@cocorograph/hub-agent 0.7.17 → 0.7.18

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.17",
3
+ "version": "0.7.18",
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
@@ -87,6 +87,7 @@ import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
87
87
  import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
88
88
  import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
89
89
  import {
90
+ boundSessionId,
90
91
  contextWindowSize,
91
92
  getSessionUsages,
92
93
  getUsage,
@@ -662,6 +663,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
662
663
  intervalMs: 5_000,
663
664
  claudeBridge,
664
665
  readinessTracker,
666
+ // tmux session_name → bind 中の session_id。turn_active を「そのペインが実際に走らせている
667
+ // セッションの jsonl」で判定するために渡す (cwd dir 内の別 jsonl 取り違え=メタ欠陥#1 の根治)。
668
+ boundSessions: ctx.tuiReboundSessions,
665
669
  })
666
670
  // bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
667
671
  // に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
@@ -1188,7 +1192,7 @@ const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
1188
1192
  // TURN_STALL_WARN_MS と揃える。
1189
1193
  const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
1190
1194
 
1191
- function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker }) {
1195
+ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker, boundSessions }) {
1192
1196
  const lastByName = new Map() // session_name → {status, context_pct}
1193
1197
  const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
1194
1198
  // (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
@@ -1304,14 +1308,17 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1304
1308
  // CockpitStatusDot の青/赤の権威ソース。サイドバーのドットと送信/停止ボタンを同じ真実で
1305
1309
  // 同期させ、Esc 中断後の青固着 (PROMPT_STALE_SHORT_MS=60s) を解消する。
1306
1310
  // - チャットモード: SDK の rate_limit_event/assistant が最も新鮮なので chat.status を採用
1307
- // - TUI モード: jsonl 末尾 assistant.stop_reason から判定 (turnActiveForCwd)
1311
+ // - TUI モード: bound session の jsonl 末尾 assistant.stop_reason から判定 (turnActiveForCwd)
1312
+ // bound session_id を渡すことで、同一 cwd dir 内の別セッション / サブエージェント /
1313
+ // headless `claude -p` / `/clear` ローテの jsonl を「最新」と取り違えて turn_active を
1314
+ // 誤る (cross-activity contamination = メタ欠陥#1) のを防ぐ。
1308
1315
  let turnActive = null
1309
1316
  if (chat?.status === "processing") {
1310
1317
  turnActive = true
1311
1318
  } else if (chat?.status === "waiting" || chat?.status === "idle") {
1312
1319
  turnActive = false
1313
1320
  } else if (s.cwd) {
1314
- turnActive = await turnActiveForCwd(s.cwd)
1321
+ turnActive = await turnActiveForCwd(s.cwd, boundSessionId(boundSessions, s.session_name))
1315
1322
  }
1316
1323
 
1317
1324
  const prev = lastByName.get(s.session_name)
@@ -1387,6 +1394,8 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1387
1394
  lastByName.delete(name)
1388
1395
  outputFlowByName.delete(name)
1389
1396
  lastTurnAtByName.delete(name)
1397
+ // 死んだセッションの bind 記録も掃除する (同名再作成時は claude.tui.bind が再登録)。
1398
+ boundSessions?.delete(name)
1390
1399
  }
1391
1400
  }
1392
1401
  }
@@ -2901,7 +2910,13 @@ async function dispatch(msg, ctx) {
2901
2910
  }
2902
2911
  case "tmux.list_sessions": {
2903
2912
  try {
2904
- const sessions = await listTmuxSessions({ plugins: ctx.plugins, logger: ctx.logger })
2913
+ // boundSessions: 各行の context% (ドーナツ) bound session jsonl から引く
2914
+ // (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
2915
+ const sessions = await listTmuxSessions({
2916
+ plugins: ctx.plugins,
2917
+ logger: ctx.logger,
2918
+ boundSessions: ctx.tuiReboundSessions,
2919
+ })
2905
2920
  // 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
2906
2921
  // レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
2907
2922
  // fs.watch 由来の session.event push は揮発性のため、frontend が新規
package/src/tmux.mjs CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  detectSessionState,
28
28
  getSessionCwd,
29
29
  } from "./state.mjs"
30
- import { getSessionUsages, jsonlContextForCwd } from "./usage.mjs"
30
+ import { boundSessionId, getSessionUsages, jsonlContextForCwd } from "./usage.mjs"
31
31
 
32
32
  const execFileP = promisify(execFile)
33
33
 
@@ -642,7 +642,9 @@ export async function listSessions(opts = {}) {
642
642
  // 3. statusLine が無ければ jsonl per-cwd の末尾 assistant.usage 由来 %。
643
643
  // 4. それも無ければ pane scrape の正規表現フォールバック。
644
644
  const fromStatusLine = cwd ? ctxByCwd.get(cwd)?.contextPercent : null
645
- const jsonlInfo = cwd ? await jsonlContextForCwd(cwd) : null
645
+ // bound session があればその jsonl context% を引く (cwd dir の別セッション jsonl の取り違え回避)。
646
+ const boundId = boundSessionId(opts.boundSessions, s.name)
647
+ const jsonlInfo = cwd ? await jsonlContextForCwd(cwd, boundId) : null
646
648
  const context_pct =
647
649
  jsonlInfo?.isReset && typeof jsonlInfo.percent === "number"
648
650
  ? jsonlInfo.percent
package/src/usage.mjs CHANGED
@@ -537,6 +537,72 @@ async function latestJsonlContext(now) {
537
537
  */
538
538
  const _jsonlSessionUuidByCwd = new Map()
539
539
 
540
+ /**
541
+ * cwd の jsonl ディレクトリから「対象セッションの jsonl 1 本」を解決する。
542
+ *
543
+ * bound `sessionId` が与えられ、その `<sessionId>.jsonl` が実在すれば **最優先で採用**する
544
+ * (= その tmux ペインが実際に bind/resume して走らせているセッション)。無ければ従来どおり
545
+ * mtime 最新へフォールバックする。
546
+ *
547
+ * 狙い: 1 つの cwd-encode dir には過去の全セッション / サブエージェント / headless `claude -p`
548
+ * (記憶蒸留 Stop hook 等) / `/clear` ローテの jsonl が堆積する (実測 52〜144 本)。mtime 最新を
549
+ * 「そのセッション」とみなすと、別アクティビティの jsonl が最新化した瞬間に turn_active /
550
+ * context% を取り違える (cross-activity contamination)。bound session_id 解決でこれを断つ。
551
+ *
552
+ * @param {string} dir cwd を encode した projects 配下のディレクトリ
553
+ * @param {string[]} files dir 内のファイル名一覧 (呼び出し側が readdir 済み)
554
+ * @param {string|null} sessionId その tmux ペインが bind しているセッション id (任意)
555
+ * @returns {Promise<{fp:string, mtimeMs:number, size:number, uuid:string, bound:boolean}|null>}
556
+ */
557
+ async function resolveTargetJsonl(dir, files, sessionId) {
558
+ if (typeof sessionId === "string" && sessionId) {
559
+ const want = `${sessionId}.jsonl`
560
+ if (files.includes(want)) {
561
+ const fp = path.join(dir, want)
562
+ const st = await fs.stat(fp).catch(() => null)
563
+ if (st) {
564
+ return { fp, mtimeMs: st.mtimeMs, size: st.size, uuid: sessionId, bound: true }
565
+ }
566
+ }
567
+ }
568
+ // フォールバック: bound 未指定 / その jsonl が未だ存在しない (起動直後など) → mtime 最新。
569
+ let newest = null
570
+ await Promise.all(
571
+ files.map(async (f) => {
572
+ if (!f.endsWith(".jsonl")) return
573
+ const fp = path.join(dir, f)
574
+ const st = await fs.stat(fp).catch(() => null)
575
+ if (!st) return
576
+ if (!newest || st.mtimeMs > newest.mtimeMs) {
577
+ newest = {
578
+ fp,
579
+ mtimeMs: st.mtimeMs,
580
+ size: st.size,
581
+ uuid: f.replace(/\.jsonl$/, ""),
582
+ bound: false,
583
+ }
584
+ }
585
+ }),
586
+ )
587
+ return newest
588
+ }
589
+
590
+ /**
591
+ * `tuiReboundSessions` (tmux session_name → session_id or "fresh:<req>") の値から、
592
+ * 実セッション id を取り出す。fresh プレースホルダ / 未 bind は null。
593
+ * turn_active / context% 解決の呼び出し側 (state loop / listSessions) が、cwd でなく
594
+ * 「そのペインが実際に走らせているセッション」で jsonl を引くために使う。
595
+ *
596
+ * @param {Map<string,string>|null|undefined} boundSessions
597
+ * @param {string} name tmux セッション名
598
+ * @returns {string|null}
599
+ */
600
+ export function boundSessionId(boundSessions, name) {
601
+ if (!boundSessions || !name) return null
602
+ const v = boundSessions.get(name)
603
+ return typeof v === "string" && v && !v.startsWith("fresh:") ? v : null
604
+ }
605
+
540
606
  /**
541
607
  * 指定 cwd の Claude セッション jsonl から context% (USED %) を算出する。
542
608
  *
@@ -563,7 +629,7 @@ const _jsonlSessionUuidByCwd = new Map()
563
629
  * @param {string} cwd
564
630
  * @returns {Promise<{ percent: number | null, uuid: string | null, isReset: boolean }>}
565
631
  */
566
- export async function jsonlContextForCwd(cwd) {
632
+ export async function jsonlContextForCwd(cwd, sessionId = null) {
567
633
  const empty = { percent: null, uuid: null, isReset: false }
568
634
  if (!cwd) return empty
569
635
  const dirName = encodeCwdToDirName(cwd)
@@ -572,23 +638,8 @@ export async function jsonlContextForCwd(cwd) {
572
638
  const files = await fs.readdir(dir).catch(() => null)
573
639
  if (!files) return empty
574
640
 
575
- let newest = null // { fp, mtimeMs, size, uuid }
576
- await Promise.all(
577
- files.map(async (f) => {
578
- if (!f.endsWith(".jsonl")) return
579
- const fp = path.join(dir, f)
580
- const st = await fs.stat(fp).catch(() => null)
581
- if (!st) return
582
- if (!newest || st.mtimeMs > newest.mtimeMs) {
583
- newest = {
584
- fp,
585
- mtimeMs: st.mtimeMs,
586
- size: st.size,
587
- uuid: f.replace(/\.jsonl$/, ""),
588
- }
589
- }
590
- }),
591
- )
641
+ // bound session_id があればその jsonl を、無ければ mtime 最新を採用 (cross-activity 汚染回避)。
642
+ const newest = await resolveTargetJsonl(dir, files, sessionId)
592
643
  if (!newest) return empty
593
644
 
594
645
  // 末尾 assistant.usage を探す。`_jsonlCtxMemo` は fp+mtime+size をキーにしているので
@@ -637,8 +688,11 @@ export async function jsonlContextForCwd(cwd) {
637
688
  })
638
689
  }
639
690
 
640
- const prevUuid = _jsonlSessionUuidByCwd.get(cwd)
641
- _jsonlSessionUuidByCwd.set(cwd, newest.uuid)
691
+ // isReset 検知のキーは bound session があればそれ、無ければ cwd。同一 cwd を複数の tmux
692
+ // セッションが共有しても、bound 時は各セッション独立に /clear (uuid 変化) を検知できる。
693
+ const trackKey = sessionId || cwd
694
+ const prevUuid = _jsonlSessionUuidByCwd.get(trackKey)
695
+ _jsonlSessionUuidByCwd.set(trackKey, newest.uuid)
642
696
  const uuidChanged = prevUuid !== undefined && prevUuid !== newest.uuid
643
697
 
644
698
  if (percent == null) {
@@ -665,6 +719,38 @@ const TURN_END_STOP_REASONS = new Set([
665
719
  "max_tokens",
666
720
  ])
667
721
 
722
+ // 末尾が実ユーザー入力 (応答未着) でも、その入力がこの時間より古ければ「中断/クラッシュ/送信
723
+ // 直後終了で応答が来ないまま終わったセッション」とみなし active=false に倒す。frontend
724
+ // events.ts の STALE_USER_TAIL_MS と揃える。ステータスドット 0.7.17 一本化で agent 側に
725
+ // 移植漏れた鮮度フロアで、Esc 中断後に放置した jsonl 等が永久に turn_active=true (青) に
726
+ // 居座り 0.7.17 の「赤=閉じろナッジ」を相殺していた問題を根治する。
727
+ // ※ tool_use 末尾 (assistant が active を立てたケース) は floor しない (frontend と同じ。
728
+ // 長時間ツール実行を誤って畳まない)。ツール中クラッシュは PID 生存判定が別途必要。
729
+ const STALE_USER_TAIL_MS = 60_000
730
+
731
+ /** jsonl の user 行から表示テキスト (string content / text ブロック連結) を取り出す。
732
+ * コマンド echo (`<command-name>`) 判定に使う。 */
733
+ function userEventText(d) {
734
+ const content = d?.message?.content
735
+ if (typeof content === "string") return content
736
+ if (Array.isArray(content)) {
737
+ return content
738
+ .map((c) => (c && c.type === "text" ? String(c.text || "") : ""))
739
+ .join("")
740
+ }
741
+ return ""
742
+ }
743
+
744
+ /** ISO 文字列 / 数値の timestamp を ms に。取れなければ null。 */
745
+ function parseEntryTs(ts) {
746
+ if (typeof ts === "number") return Number.isFinite(ts) ? ts : null
747
+ if (typeof ts === "string") {
748
+ const n = Date.parse(ts)
749
+ return Number.isFinite(n) ? n : null
750
+ }
751
+ return null
752
+ }
753
+
668
754
  /**
669
755
  * jsonl の末尾を順走査してターンが現在 active かを返す。
670
756
  *
@@ -679,13 +765,24 @@ const TURN_END_STOP_REASONS = new Set([
679
765
  * 初期値は「ターン完了状態」(lastTurnEnded=true)。空 jsonl や応答未着 のセッション
680
766
  * は active=false でフォールバックする。
681
767
  *
768
+ * 鮮度フロア: 最終的な active=true が「実ユーザー入力」由来 (応答未着) で、その入力が
769
+ * STALE_USER_TAIL_MS より古ければ false に倒す (中断/クラッシュ/放置の永久青を解消)。
770
+ * assistant の tool_use 由来 active は floor しない (長時間ツール実行を畳まない)。
771
+ * コマンド echo (`<command-name>` 注入: /clear /compact /skill 等) は LLM ターンを起こさない
772
+ * 行があるため、実ユーザー入力とみなさずスキップする (frontend isCommandEchoEvent skip と同等)。
773
+ *
682
774
  * @param {string} text jsonl の (末尾) テキスト
775
+ * @param {number} [now] テスト用の現在時刻 (ms)。省略時は Date.now()
683
776
  * @returns {boolean}
684
777
  */
685
- export function scanTurnActive(text) {
778
+ export function scanTurnActive(text, now = Date.now()) {
686
779
  if (!text) return false
687
780
  let active = false
688
781
  let lastTurnEnded = true
782
+ // 最終 active=true を立てた要因 ("user"=実入力 / "assistant"=tool_use) と、その入力の時刻。
783
+ // 鮮度フロアは "user" 由来かつ古いときだけ適用する。
784
+ let activeBy = null
785
+ let activeSinceTs = null
689
786
  const lines = text.split("\n")
690
787
  for (const line of lines) {
691
788
  if (!line) continue
@@ -700,10 +797,14 @@ export function scanTurnActive(text) {
700
797
  if (stop && TURN_END_STOP_REASONS.has(stop)) {
701
798
  active = false
702
799
  lastTurnEnded = true
800
+ activeBy = null
801
+ activeSinceTs = null
703
802
  } else {
704
803
  // tool_use / pause_turn / null / 未知 → ターン継続中扱い (安全側)
705
804
  active = true
706
805
  lastTurnEnded = false
806
+ activeBy = "assistant"
807
+ activeSinceTs = parseEntryTs(d.timestamp)
707
808
  }
708
809
  } else if (d.type === "user") {
709
810
  // 「実ユーザー入力」と「tool_result の自動 user ラッパー」を区別する。
@@ -712,12 +813,38 @@ export function scanTurnActive(text) {
712
813
  // 来たら新ターン開始 → active=true。
713
814
  const content = Array.isArray(d.message?.content) ? d.message.content : []
714
815
  const isToolResult = content.some((c) => c?.type === "tool_result")
715
- if (!isToolResult && lastTurnEnded) {
816
+ if (isToolResult) continue
817
+ const userText = userEventText(d)
818
+ // 中断マーカー ([Request interrupted by user...]) = ユーザーが Esc 等でターンを止めた。
819
+ // 直前が tool_use (active) でも明示的に完了扱いにする (= 生成は止まっている)。これが無いと
820
+ // ツール実行中の中断後に放置した jsonl が永久に active=true (青) に居座る。
821
+ if (userText.trim().startsWith("[Request interrupted by user")) {
822
+ active = false
823
+ lastTurnEnded = true
824
+ activeBy = null
825
+ activeSinceTs = null
826
+ continue
827
+ }
828
+ // コマンド echo (/clear /compact /skill 等) は LLM ターンを起こさない行があるため、
829
+ // 「実ユーザー入力」とみなさずスキップする (直前の会話状態を保つ)。
830
+ if (/<command-name>/.test(userText)) continue
831
+ if (lastTurnEnded) {
716
832
  active = true
717
833
  lastTurnEnded = false
834
+ activeBy = "user"
835
+ activeSinceTs = parseEntryTs(d.timestamp)
718
836
  }
719
837
  }
720
838
  }
839
+ // 鮮度フロア: 実ユーザー入力で active になったまま応答が来ず、その入力が古ければ idle に倒す。
840
+ if (
841
+ active &&
842
+ activeBy === "user" &&
843
+ activeSinceTs !== null &&
844
+ now - activeSinceTs > STALE_USER_TAIL_MS
845
+ ) {
846
+ return false
847
+ }
721
848
  return active
722
849
  }
723
850
 
@@ -732,14 +859,16 @@ const _turnActiveMemo = new Map()
732
859
  * 同じ真実で同期させ、Esc 中断後の「青固着 60s」を解消する。
733
860
  *
734
861
  * 動作:
735
- * 1. cwd → encoded dir → 最新 mtime*.jsonl を選ぶ
862
+ * 1. cwd → encoded dir → bound `sessionId` の jsonl (あれば最優先) / 無ければ mtime 最新を選ぶ
736
863
  * 2. 末尾 256KB を tail 読みし `scanTurnActive` でターン状態を判定
737
864
  * 3. tail で結論が出ない巨大ツール結果ケースのみ全文 read にフォールバック
738
865
  *
739
866
  * @param {string} cwd
867
+ * @param {string|null} [sessionId] その tmux ペインが bind しているセッション id。指定時はその
868
+ * jsonl を直接読み、cwd dir 内の別セッション/サブエージェント/headless jsonl の取り違えを防ぐ。
740
869
  * @returns {Promise<boolean | null>} true=生成中 / false=待機中 / null=jsonl 不在
741
870
  */
742
- export async function turnActiveForCwd(cwd) {
871
+ export async function turnActiveForCwd(cwd, sessionId = null) {
743
872
  if (!cwd) return null
744
873
  const dirName = encodeCwdToDirName(cwd)
745
874
  if (!dirName) return null
@@ -747,18 +876,8 @@ export async function turnActiveForCwd(cwd) {
747
876
  const files = await fs.readdir(dir).catch(() => null)
748
877
  if (!files) return null
749
878
 
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
- )
879
+ // bound session_id があればその jsonl を、無ければ mtime 最新を採用 (cross-activity 汚染回避)。
880
+ const newest = await resolveTargetJsonl(dir, files, sessionId)
762
881
  if (!newest) return null
763
882
 
764
883
  const memo = _turnActiveMemo.get(newest.fp)