ログ更新の自動追従を追加 (ON/OFF 切替つき)

ヘッダの「自動追従」で有効/無効を切り替える。状態は localStorage に保持し、
既定は有効。無効時は従来どおり再読込ボタンで更新する。

- Watcher スレッド: --interval 秒ごとに jsonl を stat し、ファイル数・合計
  サイズ・最新 mtime の組が変わったら rev を進めて待機中の SSE を起こす
- /api/watch (SSE) で change を通知、20 秒ごとに ping で接続維持。
  /api/rev で現在の版も取れる
- ブラウザ側は通知を 1.2 秒まとめてから表示中のものだけ黙って作り直す。
  プロジェクト/タスクの選択・スクロール位置・絞り込み・タブは維持し、
  やりとりを末尾まで見ていた場合は追記行に追従する
- 日別集計・ガントの更新は placeholder を出さない silent 経路を追加
- 後から有効にした時に、無効だった間の変更を版の比較で取り込む

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 11:21:05 +09:00
co-authored by Claude Opus 5
parent 0205404797
commit 0d9faa54bb
5 changed files with 273 additions and 19 deletions
+13 -1
View File
@@ -29,6 +29,7 @@ python chktime.py
| `--port` | 8787 | ポート番号 |
| `--host` | 127.0.0.1 | 待ち受けアドレス |
| `--dir` | `~/.claude/projects` | ログの置き場所 |
| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 |
| `--no-browser` | – | ブラウザを自動で開かない |
## 画面
@@ -75,13 +76,24 @@ python chktime.py
`CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。
横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。
## 自動追従
ヘッダの **自動追従** をオンにすると、ログが増えるたびに表示を作り直します。Claude Code を
動かしながら開いておく用途向けです。オン/オフの状態はブラウザに記憶されます(既定はオン)。
- サーバが `--interval` 秒ごとに jsonl を stat して変化を検出し、SSE(`/api/watch`)でブラウザに通知します。ブラウザ側は 1.2 秒まとめてから読み直します。
- 更新時も**選択中のプロジェクト・タスク・スクロール位置・絞り込み・開いているタブは保たれます**。やりとりを末尾まで見ていた場合は、追記された行に追従して末尾に留まります。
- ヘッダ右の表示が状態です。`● 追従中` → 変化を検出すると `● 更新あり…` → 反映後に `● 更新 hh:mm:ss`。切断されると `● 切断(再接続待ち)` となり、自動で再接続します。
- オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。
- オフのときは従来どおり **再読込** ボタンで更新します(解析キャッシュも捨てる強制再読込)。
## 解析の仕様(ざっくり)
- ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。
- `/model` などのスラッシュコマンドは `command`、コンパクト後の自動継続は `resume` とラベル表示します。
- `[Request interrupted...]`(Esc 中断)が含まれるターンには ⚠ を付けます。
- セッション再開時に書かれるメタ行(attachment / system / mode 等)は、ターンの所要時間には数えません。
- 解析結果はファイルの更新時刻+サイズでキャッシュします。ログを更新したらヘッダの **再読込** を押してください。
- 解析結果はファイルの更新時刻+サイズでキャッシュします。変わったファイルだけ再解析するので、自動追従がオンなら手動操作は不要です。
## ファイル
+99
View File
@@ -21,6 +21,7 @@ import json
import os
import re
import threading
import time
import webbrowser
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -586,6 +587,72 @@ class Store:
self._cache.clear()
class Watcher(threading.Thread):
"""projects 配下の jsonl を定期的に stat して変更を検出する (自動追従の通知用)。
ファイル数・合計サイズ・最新 mtime の組を「版」として持ち、変わったら rev を進めて
待っているクライアント (SSE) を起こす。解析結果は Store 側が (mtime, size) で
キャッシュしているので、ここでは通知だけを担う。
"""
def __init__(self, root: Path, interval=2.0):
super().__init__(daemon=True)
self.root = root
self.interval = max(0.5, interval)
self.rev = 0
self.newest = None # (project_id, session_id)
self._cv = threading.Condition()
self._token, self.newest = self._scan()
def _scan(self):
files = 0
total = 0
newest_mtime = 0.0
newest = None
if self.root.is_dir():
for entry in self.root.iterdir():
if not entry.is_dir():
continue
for path in entry.glob("*.jsonl"):
try:
stat = path.stat()
except OSError:
continue
files += 1
total += stat.st_size
if stat.st_mtime > newest_mtime:
newest_mtime = stat.st_mtime
newest = (entry.name, path.stem)
return (files, total, round(newest_mtime, 3)), newest
def run(self):
while True:
time.sleep(self.interval)
try:
token, newest = self._scan()
except OSError:
continue
with self._cv:
if token != self._token:
self._token = token
self.newest = newest
self.rev += 1
self._cv.notify_all()
def wait_change(self, rev, timeout):
"""rev より進むまで待って、現在の rev を返す (タイムアウト時は同じ値)。"""
with self._cv:
if self.rev != rev:
return self.rev
self._cv.wait(timeout)
return self.rev
def info(self):
with self._cv:
project, session = self.newest or (None, None)
return {"rev": self.rev, "project": project, "session": session, "interval": self.interval}
CWD_RE = re.compile(rb'"cwd":"((?:[^"\\]|\\.)*)"')
@@ -750,8 +817,37 @@ class Handler(BaseHTTPRequestHandler):
if route == "/api/reload":
store.clear()
return self.send_json({"ok": True})
if route == "/api/rev":
return self.send_json(self.server.watcher.info())
if route == "/api/watch":
return self.watch()
raise FileNotFoundError(route)
def watch(self):
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。"""
watcher = self.server.watcher
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
rev = watcher.rev
try:
self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n")
while True:
current = watcher.wait_change(rev, 20)
if current != rev:
rev = current
self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n")
else:
self.stream(": ping\n\n") # 接続維持
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError):
return # タブを閉じた等
def stream(self, text):
self.wfile.write(text.encode("utf-8"))
self.wfile.flush()
def static(self, route):
name = "index.html" if route in ("/", "") else route.lstrip("/")
target = (WEB_DIR / name).resolve()
@@ -793,6 +889,7 @@ def main():
parser.add_argument("--port", type=int, default=8787)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ")
parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)")
parser.add_argument("--no-browser", action="store_true")
args = parser.parse_args()
@@ -812,6 +909,8 @@ def main():
webbrowser.open(url)
return
server.store = Store(root)
server.watcher = Watcher(root, args.interval)
server.watcher.start()
print(f"chktime: {root}")
print(f" -> {url} (Ctrl+C で終了)")
if not args.no_browser:
+139 -12
View File
@@ -15,6 +15,11 @@ const state = {
daily: null,
ganttDays: 7,
gantt: null,
follow: false,
es: null,
rev: null,
followTimer: null,
refreshing: false,
};
const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca'];
@@ -87,7 +92,7 @@ async function api(path) {
// ---------------------------------------------------------------- プロジェクト
async function loadProjects() {
async function loadProjects({ select = true } = {}) {
const data = await api('/api/projects');
state.projects = data.projects;
$('#root').textContent = data.root;
@@ -113,26 +118,37 @@ async function loadProjects() {
ul.appendChild(li);
});
// 再読込では選択を保つ (null = 横断モードもそのまま)
// 再読込・自動追従では選択を保つ (null = 横断モードもそのまま)
if (state.project === undefined) state.project = data.projects.length ? data.projects[0].id : null;
else if (state.project && !data.projects.some((p) => p.id === state.project)) state.project = null;
selectProject(state.project);
markProject();
if (select) selectProject(state.project);
}
function markProject() {
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (state.project ?? '')));
$('#csv-sessions').href = state.project
? `/api/sessions?project=${encodeURIComponent(state.project)}&format=csv`
: '/api/all?format=csv';
}
async function selectProject(id) {
state.project = id;
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (id ?? '')));
$('#csv-sessions').href = id ? `/api/sessions?project=${encodeURIComponent(id)}&format=csv` : '/api/all?format=csv';
const tbody = $('#session-table tbody');
tbody.innerHTML = '<tr><td class="loading">解析中…</td></tr>';
markProject();
$('#session-table tbody').innerHTML = '<tr><td class="loading">解析中…</td></tr>';
if (state.view === 'daily') loadDaily();
else if (state.view === 'gantt') loadGantt();
await reloadSessions();
}
async function reloadSessions() {
const id = state.project;
try {
const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
state.sessions = data.sessions;
renderSessions();
} catch (e) {
tbody.innerHTML = `<tr><td class="err">${esc(e.message)}</td></tr>`;
$('#session-table tbody').innerHTML = `<tr><td class="err">${esc(e.message)}</td></tr>`;
}
}
@@ -186,8 +202,15 @@ async function selectSession(project, id) {
document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id));
updateCsv();
$('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>';
await reloadDetail();
}
async function reloadDetail() {
if (!state.session) return renderDetail();
try {
state.detail = await api(`/api/session?project=${encodeURIComponent(project)}&id=${encodeURIComponent(id)}`);
state.detail = await api(
`/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}`
);
renderDetail();
} catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
@@ -319,9 +342,11 @@ function setView(view) {
else renderDetail();
}
async function loadDaily() {
async function loadDaily({ silent = false } = {}) {
if (!silent) {
$('#detail-title').textContent = '日別集計';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
}
try {
state.daily = await api('/api/daily?' + dailyQuery());
renderDaily();
@@ -488,9 +513,11 @@ function renderDaily() {
// ---------------------------------------------------------------- ガント
async function loadGantt() {
async function loadGantt({ silent = false } = {}) {
if (!silent) {
$('#detail-title').textContent = 'ガント';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
}
try {
state.gantt = await api('/api/timeline?' + ganttQuery());
renderGantt();
@@ -680,6 +707,95 @@ function wireRanges(key, reload) {
});
}
// ---------------------------------------------------------------- 自動追従
const FOLLOW_KEY = 'chktime.follow';
const pad2 = (n) => String(n).padStart(2, '0');
function followStatus(kind, text) {
const el = $('#follow-state');
el.className = 'fstate ' + kind;
el.textContent = text ?? '';
}
function nowHms() {
const d = new Date();
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
/** ログが変わったとき、表示中のものだけを黙って作り直す (選択・スクロール位置は保つ)。 */
async function refreshFollow() {
if (state.refreshing) return;
state.refreshing = true;
const box = $('#detail-body');
const list = $('#sessions .scroll');
const boxTop = box.scrollTop;
const listTop = list.scrollTop;
const atBottom = box.scrollHeight - box.clientHeight - boxTop < 40;
try {
await loadProjects({ select: false });
await reloadSessions();
if (state.view === 'daily') await loadDaily({ silent: true });
else if (state.view === 'gantt') await loadGantt({ silent: true });
else await reloadDetail();
list.scrollTop = listTop;
// やりとりを末尾まで見ていたら、追記された行に追従する (tail -f と同じ感覚)
box.scrollTop = atBottom && state.view === 'turns' ? box.scrollHeight : boxTop;
followStatus('live', `更新 ${nowHms()}`);
} catch (e) {
followStatus('err', esc(e.message).slice(0, 40));
} finally {
state.refreshing = false;
}
}
function scheduleRefresh() {
// 書き込み中は数秒おきに飛んでくるので、まとめてから読み直す
followStatus('busy', '更新あり…');
clearTimeout(state.followTimer);
state.followTimer = setTimeout(refreshFollow, 1200);
}
function revOf(event) {
try {
return JSON.parse(event.data).rev;
} catch {
return null;
}
}
function startFollow() {
if (state.es) return;
state.follow = true;
followStatus('wait', '接続中…');
const es = new EventSource('/api/watch');
es.onopen = () => followStatus('live', '追従中');
es.addEventListener('hello', (e) => {
const rev = revOf(e);
// OFF の間や切断中の変更を取り込む (初回接続時は読み込み済みなので不要)
const stale = state.rev !== null && rev !== state.rev;
state.rev = rev;
followStatus('live', '追従中');
if (stale) scheduleRefresh();
});
es.addEventListener('change', (e) => {
state.rev = revOf(e);
scheduleRefresh();
});
es.onerror = () => followStatus('err', '切断(再接続待ち)');
state.es = es;
}
function stopFollow() {
state.follow = false;
clearTimeout(state.followTimer);
if (state.es) {
state.es.close();
state.es = null;
}
followStatus('off', '');
}
// ---------------------------------------------------------------- 起動
$('#filter').oninput = renderSessions;
@@ -707,6 +823,17 @@ window.addEventListener('resize', () => {
}, 150);
});
loadProjects().catch((e) => {
$('#follow').checked = localStorage.getItem(FOLLOW_KEY) !== '0'; // 既定は ON
$('#follow').onchange = (e) => {
localStorage.setItem(FOLLOW_KEY, e.target.checked ? '1' : '0');
if (e.target.checked) startFollow();
else stopFollow();
};
loadProjects()
.then(() => {
if ($('#follow').checked) startFollow();
})
.catch((e) => {
$('#project-list').innerHTML = `<li class="err">${esc(e.message)}</li>`;
});
+2
View File
@@ -12,6 +12,8 @@
<span class="sub" id="root"></span>
<span class="spacer"></span>
<label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label>
<label class="chk"><input type="checkbox" id="follow"> 自動追従</label>
<span id="follow-state" class="fstate"></span>
<button id="reload" title="キャッシュを破棄して再読込">再読込</button>
</header>
+14
View File
@@ -39,6 +39,20 @@ header .sub { color: var(--dim); font-size: 11px; }
header .spacer { flex: 1; }
.chk { color: var(--dim); user-select: none; cursor: pointer; }
/* 自動追従の状態表示 */
.fstate { color: var(--dim); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.fstate:not(.off):not(:empty)::before {
content: "●";
font-size: 9px;
margin-right: 4px;
vertical-align: 1px;
}
.fstate.live::before { color: var(--work); }
.fstate.busy::before { color: var(--accent); }
.fstate.wait::before { color: var(--warn); }
.fstate.err { color: var(--warn); }
.fstate.err::before { color: var(--warn); }
button, .btn {
background: var(--pane2);
color: var(--fg);