ログ更新の自動追従を追加 (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 | ポート番号 | | `--port` | 8787 | ポート番号 |
| `--host` | 127.0.0.1 | 待ち受けアドレス | | `--host` | 127.0.0.1 | 待ち受けアドレス |
| `--dir` | `~/.claude/projects` | ログの置き場所 | | `--dir` | `~/.claude/projects` | ログの置き場所 |
| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 |
| `--no-browser` | – | ブラウザを自動で開かない | | `--no-browser` | – | ブラウザを自動で開かない |
## 画面 ## 画面
@@ -75,13 +76,24 @@ python chktime.py
`CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 `CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。
横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。 横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。
## 自動追従
ヘッダの **自動追従** をオンにすると、ログが増えるたびに表示を作り直します。Claude Code を
動かしながら開いておく用途向けです。オン/オフの状態はブラウザに記憶されます(既定はオン)。
- サーバが `--interval` 秒ごとに jsonl を stat して変化を検出し、SSE(`/api/watch`)でブラウザに通知します。ブラウザ側は 1.2 秒まとめてから読み直します。
- 更新時も**選択中のプロジェクト・タスク・スクロール位置・絞り込み・開いているタブは保たれます**。やりとりを末尾まで見ていた場合は、追記された行に追従して末尾に留まります。
- ヘッダ右の表示が状態です。`● 追従中` → 変化を検出すると `● 更新あり…` → 反映後に `● 更新 hh:mm:ss`。切断されると `● 切断(再接続待ち)` となり、自動で再接続します。
- オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。
- オフのときは従来どおり **再読込** ボタンで更新します(解析キャッシュも捨てる強制再読込)。
## 解析の仕様(ざっくり) ## 解析の仕様(ざっくり)
- ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。 - ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。
- `/model` などのスラッシュコマンドは `command`、コンパクト後の自動継続は `resume` とラベル表示します。 - `/model` などのスラッシュコマンドは `command`、コンパクト後の自動継続は `resume` とラベル表示します。
- `[Request interrupted...]`(Esc 中断)が含まれるターンには ⚠ を付けます。 - `[Request interrupted...]`(Esc 中断)が含まれるターンには ⚠ を付けます。
- セッション再開時に書かれるメタ行(attachment / system / mode 等)は、ターンの所要時間には数えません。 - セッション再開時に書かれるメタ行(attachment / system / mode 等)は、ターンの所要時間には数えません。
- 解析結果はファイルの更新時刻+サイズでキャッシュします。ログを更新したらヘッダの **再読込** を押してください。 - 解析結果はファイルの更新時刻+サイズでキャッシュします。変わったファイルだけ再解析するので、自動追従がオンなら手動操作は不要です。
## ファイル ## ファイル
+99
View File
@@ -21,6 +21,7 @@ import json
import os import os
import re import re
import threading import threading
import time
import webbrowser import webbrowser
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -586,6 +587,72 @@ class Store:
self._cache.clear() 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":"((?:[^"\\]|\\.)*)"') CWD_RE = re.compile(rb'"cwd":"((?:[^"\\]|\\.)*)"')
@@ -750,8 +817,37 @@ class Handler(BaseHTTPRequestHandler):
if route == "/api/reload": if route == "/api/reload":
store.clear() store.clear()
return self.send_json({"ok": True}) 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) 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): def static(self, route):
name = "index.html" if route in ("/", "") else route.lstrip("/") name = "index.html" if route in ("/", "") else route.lstrip("/")
target = (WEB_DIR / name).resolve() target = (WEB_DIR / name).resolve()
@@ -793,6 +889,7 @@ def main():
parser.add_argument("--port", type=int, default=8787) parser.add_argument("--port", type=int, default=8787)
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ") 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") parser.add_argument("--no-browser", action="store_true")
args = parser.parse_args() args = parser.parse_args()
@@ -812,6 +909,8 @@ def main():
webbrowser.open(url) webbrowser.open(url)
return return
server.store = Store(root) server.store = Store(root)
server.watcher = Watcher(root, args.interval)
server.watcher.start()
print(f"chktime: {root}") print(f"chktime: {root}")
print(f" -> {url} (Ctrl+C で終了)") print(f" -> {url} (Ctrl+C で終了)")
if not args.no_browser: if not args.no_browser:
+145 -18
View File
@@ -15,6 +15,11 @@ const state = {
daily: null, daily: null,
ganttDays: 7, ganttDays: 7,
gantt: null, gantt: null,
follow: false,
es: null,
rev: null,
followTimer: null,
refreshing: false,
}; };
const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca']; 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'); const data = await api('/api/projects');
state.projects = data.projects; state.projects = data.projects;
$('#root').textContent = data.root; $('#root').textContent = data.root;
@@ -113,26 +118,37 @@ async function loadProjects() {
ul.appendChild(li); ul.appendChild(li);
}); });
// 再読込では選択を保つ (null = 横断モードもそのまま) // 再読込・自動追従では選択を保つ (null = 横断モードもそのまま)
if (state.project === undefined) state.project = data.projects.length ? data.projects[0].id : 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; 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) { async function selectProject(id) {
state.project = id; state.project = id;
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (id ?? ''))); markProject();
$('#csv-sessions').href = id ? `/api/sessions?project=${encodeURIComponent(id)}&format=csv` : '/api/all?format=csv'; $('#session-table tbody').innerHTML = '<tr><td class="loading">解析中…</td></tr>';
const tbody = $('#session-table tbody');
tbody.innerHTML = '<tr><td class="loading">解析中…</td></tr>';
if (state.view === 'daily') loadDaily(); if (state.view === 'daily') loadDaily();
else if (state.view === 'gantt') loadGantt(); else if (state.view === 'gantt') loadGantt();
await reloadSessions();
}
async function reloadSessions() {
const id = state.project;
try { try {
const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all'); const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
state.sessions = data.sessions; state.sessions = data.sessions;
renderSessions(); renderSessions();
} catch (e) { } 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)); document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id));
updateCsv(); updateCsv();
$('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>'; $('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>';
await reloadDetail();
}
async function reloadDetail() {
if (!state.session) return renderDetail();
try { 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(); renderDetail();
} catch (e) { } catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`; $('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
@@ -319,9 +342,11 @@ function setView(view) {
else renderDetail(); else renderDetail();
} }
async function loadDaily() { async function loadDaily({ silent = false } = {}) {
$('#detail-title').textContent = '日別集計'; if (!silent) {
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>'; $('#detail-title').textContent = '日別集計';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
}
try { try {
state.daily = await api('/api/daily?' + dailyQuery()); state.daily = await api('/api/daily?' + dailyQuery());
renderDaily(); renderDaily();
@@ -488,9 +513,11 @@ function renderDaily() {
// ---------------------------------------------------------------- ガント // ---------------------------------------------------------------- ガント
async function loadGantt() { async function loadGantt({ silent = false } = {}) {
$('#detail-title').textContent = 'ガント'; if (!silent) {
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>'; $('#detail-title').textContent = 'ガント';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
}
try { try {
state.gantt = await api('/api/timeline?' + ganttQuery()); state.gantt = await api('/api/timeline?' + ganttQuery());
renderGantt(); 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; $('#filter').oninput = renderSessions;
@@ -707,6 +823,17 @@ window.addEventListener('resize', () => {
}, 150); }, 150);
}); });
loadProjects().catch((e) => { $('#follow').checked = localStorage.getItem(FOLLOW_KEY) !== '0'; // 既定は ON
$('#project-list').innerHTML = `<li class="err">${esc(e.message)}</li>`; $('#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="sub" id="root"></span>
<span class="spacer"></span> <span class="spacer"></span>
<label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label> <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> <button id="reload" title="キャッシュを破棄して再読込">再読込</button>
</header> </header>
+14
View File
@@ -39,6 +39,20 @@ header .sub { color: var(--dim); font-size: 11px; }
header .spacer { flex: 1; } header .spacer { flex: 1; }
.chk { color: var(--dim); user-select: none; cursor: pointer; } .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 { button, .btn {
background: var(--pane2); background: var(--pane2);
color: var(--fg); color: var(--fg);