@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 +1 -1
- package/src/main.mjs +40 -3
- package/src/tmux.mjs +4 -2
- package/src/usage.mjs +275 -20
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
576
|
-
await
|
|
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
|
-
|
|
641
|
-
|
|
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
|
* から推定する。
|