@cocorograph/hub-agent 0.7.16 → 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.16",
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,10 +87,12 @@ 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,
93
94
  recordChatRateLimit,
95
+ turnActiveForCwd,
94
96
  } from "./usage.mjs"
95
97
  import {
96
98
  clearChatSignal,
@@ -661,6 +663,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
661
663
  intervalMs: 5_000,
662
664
  claudeBridge,
663
665
  readinessTracker,
666
+ // tmux session_name → bind 中の session_id。turn_active を「そのペインが実際に走らせている
667
+ // セッションの jsonl」で判定するために渡す (cwd dir 内の別 jsonl 取り違え=メタ欠陥#1 の根治)。
668
+ boundSessions: ctx.tuiReboundSessions,
664
669
  })
665
670
  // bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
666
671
  // に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
@@ -1187,7 +1192,7 @@ const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
1187
1192
  // TURN_STALL_WARN_MS と揃える。
1188
1193
  const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
1189
1194
 
1190
- function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker }) {
1195
+ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker, boundSessions }) {
1191
1196
  const lastByName = new Map() // session_name → {status, context_pct}
1192
1197
  const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
1193
1198
  // (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
@@ -1298,6 +1303,24 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1298
1303
  stable,
1299
1304
  now: nowMs,
1300
1305
  })
1306
+
1307
+ // turn_active: ターンが現在進行中か (true=生成/ツール実行中 / false=待機中 / null=不明)。
1308
+ // CockpitStatusDot の青/赤の権威ソース。サイドバーのドットと送信/停止ボタンを同じ真実で
1309
+ // 同期させ、Esc 中断後の青固着 (PROMPT_STALE_SHORT_MS=60s) を解消する。
1310
+ // - チャットモード: SDK の rate_limit_event/assistant が最も新鮮なので chat.status を採用
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) のを防ぐ。
1315
+ let turnActive = null
1316
+ if (chat?.status === "processing") {
1317
+ turnActive = true
1318
+ } else if (chat?.status === "waiting" || chat?.status === "idle") {
1319
+ turnActive = false
1320
+ } else if (s.cwd) {
1321
+ turnActive = await turnActiveForCwd(s.cwd, boundSessionId(boundSessions, s.session_name))
1322
+ }
1323
+
1301
1324
  const prev = lastByName.get(s.session_name)
1302
1325
  if (
1303
1326
  !prev ||
@@ -1307,7 +1330,8 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1307
1330
  prev.stable !== stable ||
1308
1331
  prev.proc_busy !== procBusy ||
1309
1332
  prev.child_busy !== childBusy ||
1310
- prev.stalled !== stalled
1333
+ prev.stalled !== stalled ||
1334
+ prev.turn_active !== turnActive
1311
1335
  ) {
1312
1336
  lastByName.set(s.session_name, {
1313
1337
  status,
@@ -1317,6 +1341,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1317
1341
  proc_busy: procBusy,
1318
1342
  child_busy: childBusy,
1319
1343
  stalled,
1344
+ turn_active: turnActive,
1320
1345
  })
1321
1346
  // 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
1322
1347
  // 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
@@ -1333,6 +1358,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1333
1358
  armed,
1334
1359
  stable,
1335
1360
  context_pct: contextPct,
1361
+ turn_active: turnActive,
1336
1362
  },
1337
1363
  "session.state push",
1338
1364
  )
@@ -1349,6 +1375,9 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1349
1375
  // 出力残り火 (proc_busy 残存) でアイドルなのにキューへ逃がす誤キュー (症状A) を断つために使う。
1350
1376
  child_busy: childBusy,
1351
1377
  stalled,
1378
+ // turn_active: ステータスドット 4 状態統一の権威ソース。null は旧クライアント互換
1379
+ // (frontend は欠落時に旧 prompt_submit/stale 降格ロジックへフォールバック)。
1380
+ turn_active: turnActive,
1352
1381
  })
1353
1382
  }
1354
1383
  // 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
@@ -1365,6 +1394,8 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1365
1394
  lastByName.delete(name)
1366
1395
  outputFlowByName.delete(name)
1367
1396
  lastTurnAtByName.delete(name)
1397
+ // 死んだセッションの bind 記録も掃除する (同名再作成時は claude.tui.bind が再登録)。
1398
+ boundSessions?.delete(name)
1368
1399
  }
1369
1400
  }
1370
1401
  }
@@ -2879,7 +2910,13 @@ async function dispatch(msg, ctx) {
2879
2910
  }
2880
2911
  case "tmux.list_sessions": {
2881
2912
  try {
2882
- 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
+ })
2883
2920
  // 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
2884
2921
  // レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
2885
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) {
@@ -657,6 +711,207 @@ export function _resetJsonlSessionUuids() {
657
711
  _jsonlSessionUuidByCwd.clear()
658
712
  }
659
713
 
714
+ // ターン完了を示す Anthropic API `stop_reason`。これ以外 (代表的には `tool_use`)
715
+ // は「ターン継続中 = 生成 / ツール処理中」とみなす。
716
+ const TURN_END_STOP_REASONS = new Set([
717
+ "end_turn",
718
+ "stop_sequence",
719
+ "max_tokens",
720
+ ])
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
+
754
+ /**
755
+ * jsonl の末尾を順走査してターンが現在 active かを返す。
756
+ *
757
+ * ステートマシン:
758
+ * - `assistant` 行を見たら `stop_reason` でターン状態を更新する
759
+ * - end_turn / stop_sequence / max_tokens → 完了 (active=false)
760
+ * - tool_use (or それ以外) → 継続 (active=true)
761
+ * - `user` 行を見たとき、それが「実ユーザー入力」(tool_result でない) かつ
762
+ * 直前ターンが完了済みだったら、新ターンが開始されたので active=true
763
+ * - その他の型 (attachment / mode / hook_* / file-history-snapshot 等) は無視
764
+ *
765
+ * 初期値は「ターン完了状態」(lastTurnEnded=true)。空 jsonl や応答未着 のセッション
766
+ * は active=false でフォールバックする。
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
+ *
774
+ * @param {string} text jsonl の (末尾) テキスト
775
+ * @param {number} [now] テスト用の現在時刻 (ms)。省略時は Date.now()
776
+ * @returns {boolean}
777
+ */
778
+ export function scanTurnActive(text, now = Date.now()) {
779
+ if (!text) return false
780
+ let active = false
781
+ let lastTurnEnded = true
782
+ // 最終 active=true を立てた要因 ("user"=実入力 / "assistant"=tool_use) と、その入力の時刻。
783
+ // 鮮度フロアは "user" 由来かつ古いときだけ適用する。
784
+ let activeBy = null
785
+ let activeSinceTs = null
786
+ const lines = text.split("\n")
787
+ for (const line of lines) {
788
+ if (!line) continue
789
+ let d
790
+ try {
791
+ d = JSON.parse(line)
792
+ } catch {
793
+ continue
794
+ }
795
+ if (d.type === "assistant") {
796
+ const stop = d.message?.stop_reason
797
+ if (stop && TURN_END_STOP_REASONS.has(stop)) {
798
+ active = false
799
+ lastTurnEnded = true
800
+ activeBy = null
801
+ activeSinceTs = null
802
+ } else {
803
+ // tool_use / pause_turn / null / 未知 → ターン継続中扱い (安全側)
804
+ active = true
805
+ lastTurnEnded = false
806
+ activeBy = "assistant"
807
+ activeSinceTs = parseEntryTs(d.timestamp)
808
+ }
809
+ } else if (d.type === "user") {
810
+ // 「実ユーザー入力」と「tool_result の自動 user ラッパー」を区別する。
811
+ // tool_result が含まれる user は前ターンの一部 (claude code 内部生成) なので、
812
+ // ターン状態を変えない。実ユーザー入力 (text/image/document) が直前ターン完了後に
813
+ // 来たら新ターン開始 → active=true。
814
+ const content = Array.isArray(d.message?.content) ? d.message.content : []
815
+ const isToolResult = content.some((c) => c?.type === "tool_result")
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) {
832
+ active = true
833
+ lastTurnEnded = false
834
+ activeBy = "user"
835
+ activeSinceTs = parseEntryTs(d.timestamp)
836
+ }
837
+ }
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
+ }
848
+ return active
849
+ }
850
+
851
+ /** P5: turnActiveForCwd のメモ (newest fp + mtime + size をキー)。 */
852
+ const _turnActiveMemo = new Map()
853
+
854
+ /**
855
+ * 指定 cwd の Claude セッション jsonl から「現在ターンが進行中か」を返す。
856
+ *
857
+ * `CockpitStatusDot` の中央ドットの判定権威ソース。サイドバーのドット (非選択
858
+ * セッション) と送信/停止ボタン (選択セッション、`turnActive` ストリーム由来) を
859
+ * 同じ真実で同期させ、Esc 中断後の「青固着 60s」を解消する。
860
+ *
861
+ * 動作:
862
+ * 1. cwd → encoded dir → bound `sessionId` の jsonl (あれば最優先) / 無ければ mtime 最新を選ぶ
863
+ * 2. 末尾 256KB を tail 読みし `scanTurnActive` でターン状態を判定
864
+ * 3. tail で結論が出ない巨大ツール結果ケースのみ全文 read にフォールバック
865
+ *
866
+ * @param {string} cwd
867
+ * @param {string|null} [sessionId] その tmux ペインが bind しているセッション id。指定時はその
868
+ * jsonl を直接読み、cwd dir 内の別セッション/サブエージェント/headless jsonl の取り違えを防ぐ。
869
+ * @returns {Promise<boolean | null>} true=生成中 / false=待機中 / null=jsonl 不在
870
+ */
871
+ export async function turnActiveForCwd(cwd, sessionId = null) {
872
+ if (!cwd) return null
873
+ const dirName = encodeCwdToDirName(cwd)
874
+ if (!dirName) return null
875
+ const dir = path.join(projectsDir(), dirName)
876
+ const files = await fs.readdir(dir).catch(() => null)
877
+ if (!files) return null
878
+
879
+ // bound session_id があればその jsonl を、無ければ mtime 最新を採用 (cross-activity 汚染回避)。
880
+ const newest = await resolveTargetJsonl(dir, files, sessionId)
881
+ if (!newest) return null
882
+
883
+ const memo = _turnActiveMemo.get(newest.fp)
884
+ if (memo && memo.mtimeMs === newest.mtimeMs && memo.size === newest.size) {
885
+ return memo.result
886
+ }
887
+
888
+ // 256KB tail で多くの場合は決着がつく (1 ターン分の assistant + tool_use/result サイクル) 。
889
+ // tail 内に assistant 行が 0 件のみのケース (= 巨大 tool_result で先頭を tail から外れた)
890
+ // に備え、結果が「全行で active 状態が変化しない」場合は全文読みにフォールバックする。
891
+ const tail = await readTail(newest.fp, 256 * 1024)
892
+ let active = false
893
+ if (tail != null) {
894
+ active = scanTurnActive(tail)
895
+ }
896
+ // tail で active=false の場合のみ、巨大 tool_result で先頭の assistant 行が
897
+ // 落ちて誤って完了扱いになっていないか全文で確認する (高コストだが必要時のみ)。
898
+ if (active === false) {
899
+ const full = await readOrNull(newest.fp)
900
+ if (full != null) active = scanTurnActive(full)
901
+ }
902
+ _turnActiveMemo.set(newest.fp, {
903
+ mtimeMs: newest.mtimeMs,
904
+ size: newest.size,
905
+ result: active,
906
+ })
907
+ return active
908
+ }
909
+
910
+ /** テスト用: turnActive メモをクリアする。 */
911
+ export function _resetTurnActiveMemo() {
912
+ _turnActiveMemo.clear()
913
+ }
914
+
660
915
  /**
661
916
  * 5h / 7d 集計の UsageStats を返す。statusLine が無い環境では transcript
662
917
  * から推定する。