throughline 0.3.3 → 0.3.4

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/README.md CHANGED
@@ -178,6 +178,19 @@ Example output (real values from a running 1M-context Opus session):
178
178
  - **Line-wrap safe.** Each line is truncated to `process.stdout.columns - 1`
179
179
  before drawing, preserving ANSI color codes. The redraw cursor math cannot
180
180
  desync on narrow terminals.
181
+ - **Resize resilient.** Column width is polled every second, so pane drags that
182
+ don't fire a terminal `resize` event (common in VS Code's integrated
183
+ terminal) still trigger a full redraw.
184
+ - **Per-row "last updated" stamp.** Each session row ends with `(24m ago)` so
185
+ you can tell a frozen display apart from an idle session at a glance. When
186
+ you need more detail, `throughline doctor --session <id-prefix>` compares the
187
+ state file against the actual transcript JSONL and flags drift, idle time,
188
+ and `/clear`-induced transcript path staleness.
189
+ - **State-backed usage snapshot.** When the Stop hook finishes a turn it
190
+ persists the latest `tokens / model / contextWindowSize` back into the state
191
+ file. The monitor prefers this snapshot over re-reading the JSONL, which
192
+ removes a source of flicker when the transcript path in state drifts from
193
+ the one Claude Code is currently appending to.
181
194
 
182
195
  ### VS Code auto-start (automatic)
183
196
 
@@ -243,6 +256,7 @@ entry to the `tasks` array yourself:
243
256
  | `throughline detail <time>` | Retrieve L2 body text and L3 tool I/O for a turn (see below) |
244
257
  | `throughline save-inflight` | Called by `/tl` to attach an in-flight memo (stdin) to the current baton |
245
258
  | `throughline doctor` | Check Node version, hook registration, DB writability, PATH |
259
+ | `throughline doctor --session <id-prefix>` | Diagnose a specific session — detect state/transcript drift, idle vs. stuck |
246
260
  | `throughline status` | Print DB statistics (sessions, skeletons, bodies, details) |
247
261
  | `throughline --version` | Print the installed version |
248
262
 
@@ -45,7 +45,7 @@ switch (cmd) {
45
45
  await (await import('../src/cli/save-inflight.mjs')).run();
46
46
  break;
47
47
  case 'doctor':
48
- await (await import('../src/cli/doctor.mjs')).run();
48
+ await (await import('../src/cli/doctor.mjs')).run(rest);
49
49
  break;
50
50
  case 'status':
51
51
  await (await import('../src/cli/status.mjs')).run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -1,21 +1,31 @@
1
1
  /**
2
- * throughline doctor — 環境チェック
2
+ * throughline doctor — 環境チェック + セッション診断
3
3
  *
4
- * チェック項目:
4
+ * 通常: throughline doctor
5
5
  * - Node.js バージョン >= 22.5
6
6
  * - node:sqlite が使えるか
7
7
  * - ~/.throughline/throughline.db が書き込み可能か
8
8
  * - ~/.claude/settings.json に Throughline hook が登録されているか
9
+ *
10
+ * セッション診断: throughline doctor --session <id-prefix>
11
+ * - 特定セッションの state ファイルと transcript JSONL の整合性をチェック
12
+ * - 「モニターが止まって見える」ときの真因切り分け用
13
+ * (本当にアイドルか、state の transcriptPath が古い JSONL を指しているか)
9
14
  */
10
15
 
11
- import { existsSync, accessSync, readFileSync, constants } from 'node:fs';
12
- import { join } from 'node:path';
16
+ import { existsSync, accessSync, readFileSync, constants, readdirSync, statSync } from 'node:fs';
17
+ import { join, dirname } from 'node:path';
13
18
  import { homedir } from 'node:os';
14
19
  import { execSync } from 'node:child_process';
20
+ import { getStateDir } from '../state-file.mjs';
21
+ import { readLatestUsage } from '../transcript-usage.mjs';
15
22
 
16
23
  const GREEN = '\x1b[32m✓\x1b[0m';
17
24
  const RED = '\x1b[31m✗\x1b[0m';
18
25
  const YELLOW = '\x1b[33m!\x1b[0m';
26
+ const DIM = '\x1b[2m';
27
+ const RESET = '\x1b[0m';
28
+ const BOLD = '\x1b[1m';
19
29
 
20
30
  async function check(label, fn) {
21
31
  try {
@@ -32,7 +42,244 @@ async function check(label, fn) {
32
42
  }
33
43
  }
34
44
 
35
- export async function run() {
45
+ function parseArgs(argv) {
46
+ const args = { session: null };
47
+ for (let i = 0; i < argv.length; i++) {
48
+ if (argv[i] === '--session') {
49
+ const value = argv[i + 1];
50
+ if (value === undefined || value.startsWith('--')) {
51
+ throw new Error('--session requires a session id prefix');
52
+ }
53
+ args.session = value;
54
+ i++;
55
+ }
56
+ }
57
+ return args;
58
+ }
59
+
60
+ function formatAgo(ms) {
61
+ if (!Number.isFinite(ms) || ms < 0) return '?';
62
+ const sec = Math.floor(ms / 1000);
63
+ if (sec < 60) return `${sec}s ago`;
64
+ const min = Math.floor(sec / 60);
65
+ if (min < 60) return `${min}m ago`;
66
+ const hr = Math.floor(min / 60);
67
+ if (hr < 24) return `${hr}h ago`;
68
+ const day = Math.floor(hr / 24);
69
+ return `${day}d ago`;
70
+ }
71
+
72
+ function formatTs(ms) {
73
+ if (!Number.isFinite(ms)) return '?';
74
+ const d = new Date(ms);
75
+ const pad = (n) => String(n).padStart(2, '0');
76
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
77
+ }
78
+
79
+ function formatBytes(n) {
80
+ if (!Number.isFinite(n) || n < 0) return '?';
81
+ if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + ' GB';
82
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + ' MB';
83
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + ' kB';
84
+ return `${n} B`;
85
+ }
86
+
87
+ /**
88
+ * transcript JSONL を末尾から走査して最後の assistant エントリの timestamp を返す。
89
+ * JSONL は append-only だが巨大化しうるので、末尾 256 KB だけ読んで逆順走査する。
90
+ * @param {string} transcriptPath
91
+ * @returns {{ ts: number | null, usage: object | null }}
92
+ */
93
+ function tailLatestAssistantTs(transcriptPath) {
94
+ try {
95
+ const stat = statSync(transcriptPath);
96
+ // シンプル化: 現状の全ファイル read で十分(モニターも全 read している)。
97
+ // 巨大 JSONL 対策は readLatestUsage 側の将来最適化に任せる。
98
+ const raw = readFileSync(transcriptPath, 'utf8');
99
+ const lines = raw.split('\n');
100
+ let latestTs = null;
101
+ for (let i = lines.length - 1; i >= 0; i--) {
102
+ const trimmed = lines[i].trim();
103
+ if (!trimmed) continue;
104
+ let entry;
105
+ try {
106
+ entry = JSON.parse(trimmed);
107
+ } catch {
108
+ continue;
109
+ }
110
+ if (entry.type !== 'assistant') continue;
111
+ const ts = entry.timestamp ?? entry.ts ?? null;
112
+ if (ts) {
113
+ latestTs = typeof ts === 'string' ? Date.parse(ts) : ts;
114
+ break;
115
+ }
116
+ }
117
+ return { ts: latestTs, fileMtime: stat.mtimeMs, size: stat.size };
118
+ } catch (err) {
119
+ throw new Error(`transcript read failed: ${err.message}`);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 同じプロジェクトディレクトリ内の最新 JSONL を返す(transcript 差し替え検出用)。
125
+ * state の transcriptPath と比較して、指し先が「最新」でなければズレている可能性。
126
+ */
127
+ function findLatestJsonlInSameDir(transcriptPath) {
128
+ try {
129
+ const dir = dirname(transcriptPath);
130
+ if (!existsSync(dir)) return null;
131
+ const files = readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
132
+ if (files.length === 0) return null;
133
+ let best = null;
134
+ for (const name of files) {
135
+ const full = join(dir, name);
136
+ try {
137
+ const mt = statSync(full).mtimeMs;
138
+ if (!best || mt > best.mtimeMs) best = { path: full, mtimeMs: mt };
139
+ } catch {
140
+ /* skip */
141
+ }
142
+ }
143
+ return best;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function isPidAlive(pid) {
150
+ if (!Number.isInteger(pid) || pid <= 0) return false;
151
+ try {
152
+ process.kill(pid, 0);
153
+ return true;
154
+ } catch (err) {
155
+ return err.code === 'EPERM'; // 他ユーザー所有プロセスは生きている扱い
156
+ }
157
+ }
158
+
159
+ function runSessionDiagnosis(prefix) {
160
+ const stateDir = getStateDir();
161
+ if (!existsSync(stateDir)) {
162
+ console.log(`${RED} state ディレクトリが存在しません: ${stateDir}`);
163
+ console.log(`${DIM} → Throughline が一度も動作していない可能性。throughline install してから Claude Code を起動してください。${RESET}`);
164
+ return;
165
+ }
166
+ const entries = readdirSync(stateDir)
167
+ .filter((n) => n.endsWith('.json'))
168
+ .filter((n) => n.startsWith(prefix) || n.replace(/\.json$/, '').startsWith(prefix));
169
+ if (entries.length === 0) {
170
+ console.log(`${RED} prefix "${prefix}" に一致する state ファイルが見つかりません`);
171
+ console.log(`${DIM} → ~/.throughline/state/ を ls して session_id を確認してください。${RESET}`);
172
+ return;
173
+ }
174
+ if (entries.length > 1) {
175
+ console.log(`${YELLOW} 複数のセッションが prefix に一致しました:`);
176
+ for (const name of entries) console.log(` - ${name}`);
177
+ console.log(`${DIM} → もう少し長い prefix を指定してください。${RESET}`);
178
+ return;
179
+ }
180
+
181
+ const name = entries[0];
182
+ const stateFile = join(stateDir, name);
183
+ const sessionId = name.replace(/\.json$/, '');
184
+ console.log(`${BOLD}[Session ${sessionId}]${RESET}\n`);
185
+
186
+ let state;
187
+ try {
188
+ state = JSON.parse(readFileSync(stateFile, 'utf8'));
189
+ } catch (err) {
190
+ console.log(`${RED} state ファイル読み込み失敗: ${err.message}`);
191
+ return;
192
+ }
193
+
194
+ const now = Date.now();
195
+ console.log(` state file: ${stateFile}`);
196
+ console.log(` updatedAt: ${formatTs(state.updatedAt)} (${formatAgo(now - (state.updatedAt ?? 0))})`);
197
+ console.log(` projectPath: ${state.projectPath ?? '(未設定)'}`);
198
+ console.log(` transcriptPath: ${state.transcriptPath ?? '(未設定)'}`);
199
+ if (state.pid) {
200
+ const alive = isPidAlive(state.pid);
201
+ console.log(` pid: ${state.pid} (${alive ? 'alive' : 'dead'})`);
202
+ }
203
+ if (state.usage) {
204
+ const u = state.usage;
205
+ const pct = u.contextWindowSize ? Math.round((u.tokens / u.contextWindowSize) * 100) : 0;
206
+ console.log(` usage (snapshot): ${u.tokens?.toLocaleString()} tokens (${pct}% of ${u.contextWindowSize?.toLocaleString()})`);
207
+ console.log(` model: ${u.model ?? '?'}`);
208
+ } else {
209
+ console.log(` usage (snapshot): ${DIM}(未記録 — 旧バージョンで書かれた state、または Stop が 1 度も走っていない)${RESET}`);
210
+ }
211
+ console.log('');
212
+
213
+ if (!state.transcriptPath) {
214
+ console.log(`${YELLOW} transcriptPath が state に含まれていません — 診断不能`);
215
+ return;
216
+ }
217
+
218
+ if (!existsSync(state.transcriptPath)) {
219
+ console.log(` transcript: ${RED}存在しない${RESET}`);
220
+ console.log(`${DIM} → state の transcriptPath が古い or /clear で消えた可能性。新しい発話で state が再生成されます。${RESET}`);
221
+ return;
222
+ }
223
+
224
+ let tail;
225
+ try {
226
+ tail = tailLatestAssistantTs(state.transcriptPath);
227
+ } catch (err) {
228
+ console.log(` transcript: ${RED}${err.message}${RESET}`);
229
+ return;
230
+ }
231
+ console.log(` transcript:`);
232
+ console.log(` size: ${formatBytes(tail.size)}`);
233
+ console.log(` mtime: ${formatTs(tail.fileMtime)} (${formatAgo(now - tail.fileMtime)})`);
234
+ if (tail.ts) {
235
+ console.log(` latest assistant entry: ${formatTs(tail.ts)} (${formatAgo(now - tail.ts)})`);
236
+ } else {
237
+ console.log(` latest assistant entry: ${DIM}(未検出 — usage 付きの assistant エントリがまだ無い)${RESET}`);
238
+ }
239
+
240
+ const live = readLatestUsage(state.transcriptPath);
241
+ if (live) {
242
+ const pct = live.contextWindowSize ? Math.round((live.tokens / live.contextWindowSize) * 100) : 0;
243
+ console.log(` usage (live): ${live.tokens?.toLocaleString()} tokens (${pct}% of ${live.contextWindowSize?.toLocaleString()})`);
244
+ }
245
+ console.log('');
246
+
247
+ // diagnosis
248
+ console.log(` diagnosis:`);
249
+ const latestInDir = findLatestJsonlInSameDir(state.transcriptPath);
250
+ if (latestInDir && latestInDir.path !== state.transcriptPath && latestInDir.mtimeMs > tail.fileMtime) {
251
+ console.log(` ${RED}state points to old JSONL${RESET}`);
252
+ console.log(` state: ${state.transcriptPath} (${formatAgo(now - tail.fileMtime)})`);
253
+ console.log(` newer: ${latestInDir.path} (${formatAgo(now - latestInDir.mtimeMs)})`);
254
+ console.log(`${DIM} → 次の発話で state が自動修復されます。それでも直らない場合は state ファイルを削除してください。${RESET}`);
255
+ } else {
256
+ console.log(` ${GREEN}state and transcript are consistent${RESET}`);
257
+ }
258
+ const idleMs = now - tail.fileMtime;
259
+ if (idleMs > 10 * 60 * 1000) {
260
+ console.log(` ${YELLOW}no transcript activity in ${formatAgo(idleMs)} — session likely idle${RESET}`);
261
+ console.log(`${DIM} → Claude Code でこのセッションが動いていれば transcript は必ず太ります。太っていないなら本当にアイドル。${RESET}`);
262
+ }
263
+ if (state.usage && live && state.usage.tokens !== live.tokens) {
264
+ console.log(` ${YELLOW}state.usage snapshot (${state.usage.tokens}) != live transcript (${live.tokens})${RESET}`);
265
+ console.log(`${DIM} → Stop が一度走った後に更に assistant エントリが追記された状態。次の Stop で揃います。${RESET}`);
266
+ }
267
+ }
268
+
269
+ export async function run(argv = []) {
270
+ let args;
271
+ try {
272
+ args = parseArgs(argv);
273
+ } catch (err) {
274
+ process.stderr.write(`[throughline doctor] ${err.message}\n`);
275
+ process.exit(2);
276
+ }
277
+
278
+ if (args.session) {
279
+ runSessionDiagnosis(args.session);
280
+ return;
281
+ }
282
+
36
283
  console.log('throughline doctor\n');
37
284
 
38
285
  // Node.js バージョン
@@ -95,4 +342,15 @@ export async function run() {
95
342
  });
96
343
 
97
344
  console.log('');
345
+ console.log(`${DIM}ヒント: 特定セッションが止まって見えるときは ${RESET}throughline doctor --session <id-prefix>${DIM} で診断できます。${RESET}`);
98
346
  }
347
+
348
+ // テスト用エクスポート
349
+ export const _internal = {
350
+ parseArgs,
351
+ formatAgo,
352
+ formatBytes,
353
+ runSessionDiagnosis,
354
+ isPidAlive,
355
+ findLatestJsonlInSameDir,
356
+ };
@@ -0,0 +1,109 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, utimesSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { _internal } from './doctor.mjs';
8
+
9
+ const { parseArgs, formatAgo, formatBytes, findLatestJsonlInSameDir, isPidAlive } = _internal;
10
+
11
+ // ─── parseArgs ──────────────────────────────────────────────────────
12
+
13
+ test('parseArgs: 引数なしは session null', () => {
14
+ assert.deepEqual(parseArgs([]), { session: null });
15
+ });
16
+
17
+ test('parseArgs: --session <prefix>', () => {
18
+ assert.deepEqual(parseArgs(['--session', 'abc']), { session: 'abc' });
19
+ });
20
+
21
+ test('parseArgs: --session の値欠落は throw', () => {
22
+ assert.throws(() => parseArgs(['--session']), /session id prefix/);
23
+ });
24
+
25
+ test('parseArgs: --session の次が別フラグなら throw', () => {
26
+ assert.throws(() => parseArgs(['--session', '--other']), /session id prefix/);
27
+ });
28
+
29
+ // ─── formatAgo ──────────────────────────────────────────────────────
30
+
31
+ test('formatAgo: 60 秒未満は秒表示', () => {
32
+ assert.equal(formatAgo(30_000), '30s ago');
33
+ });
34
+
35
+ test('formatAgo: 60 分未満は分表示', () => {
36
+ assert.equal(formatAgo(5 * 60_000), '5m ago');
37
+ });
38
+
39
+ test('formatAgo: 24 時間未満は時表示', () => {
40
+ assert.equal(formatAgo(3 * 60 * 60_000), '3h ago');
41
+ });
42
+
43
+ test('formatAgo: 24 時間以上は日表示', () => {
44
+ assert.equal(formatAgo(2 * 24 * 60 * 60_000), '2d ago');
45
+ });
46
+
47
+ test('formatAgo: 無効値', () => {
48
+ assert.equal(formatAgo(NaN), '?');
49
+ assert.equal(formatAgo(-1), '?');
50
+ });
51
+
52
+ // ─── formatBytes ────────────────────────────────────────────────────
53
+
54
+ test('formatBytes: KB/MB/GB の切り替え', () => {
55
+ assert.equal(formatBytes(999), '999 B');
56
+ assert.equal(formatBytes(1_500), '1.5 kB');
57
+ assert.equal(formatBytes(1_500_000), '1.50 MB');
58
+ assert.equal(formatBytes(2_000_000_000), '2.00 GB');
59
+ });
60
+
61
+ test('formatBytes: 無効値', () => {
62
+ assert.equal(formatBytes(NaN), '?');
63
+ assert.equal(formatBytes(-1), '?');
64
+ });
65
+
66
+ // ─── findLatestJsonlInSameDir ──────────────────────────────────────
67
+
68
+ test('findLatestJsonlInSameDir: 同じディレクトリ内の最新 JSONL を返す', () => {
69
+ const dir = mkdtempSync(join(tmpdir(), 'tl-doctor-'));
70
+ try {
71
+ const older = join(dir, 'a.jsonl');
72
+ const newer = join(dir, 'b.jsonl');
73
+ writeFileSync(older, 'x');
74
+ // mtime を強制的に差をつけるため書き込み間隔を開けたいが、連続 write だと同 ms 。
75
+ // ここでは newer の内容を後に書いて、後書きが newer の mtime を十分大きくする。
76
+ const now = Date.now();
77
+ writeFileSync(newer, 'y');
78
+ // older の mtime を古く設定
79
+ utimesSync(older, new Date(now - 10000), new Date(now - 10000));
80
+ utimesSync(newer, new Date(now), new Date(now));
81
+ const result = findLatestJsonlInSameDir(older);
82
+ assert.ok(result);
83
+ assert.equal(result.path, newer);
84
+ } finally {
85
+ rmSync(dir, { recursive: true, force: true });
86
+ }
87
+ });
88
+
89
+ test('findLatestJsonlInSameDir: 存在しないパスは null', () => {
90
+ assert.equal(findLatestJsonlInSameDir('/does/not/exist/x.jsonl'), null);
91
+ });
92
+
93
+ // ─── isPidAlive ─────────────────────────────────────────────────────
94
+
95
+ test('isPidAlive: 自身の PID は alive', () => {
96
+ assert.equal(isPidAlive(process.pid), true);
97
+ });
98
+
99
+ test('isPidAlive: 不正な値は false', () => {
100
+ assert.equal(isPidAlive(0), false);
101
+ assert.equal(isPidAlive(-1), false);
102
+ assert.equal(isPidAlive(null), false);
103
+ assert.equal(isPidAlive(undefined), false);
104
+ });
105
+
106
+ test('isPidAlive: 存在しない PID は false', () => {
107
+ // 巨大な PID はほぼ確実に未使用
108
+ assert.equal(isPidAlive(2_147_483_646), false);
109
+ });
@@ -37,9 +37,14 @@ export function normalizeProjectPath(p) {
37
37
 
38
38
  /**
39
39
  * セッション状態ファイルを書く
40
- * @param {{sessionId: string, projectPath: string, transcriptPath: string, pid: number}} data
40
+ * @param {{sessionId: string, projectPath: string, transcriptPath: string, pid: number, usage?: object|null}} data
41
+ *
42
+ * usage: monitor が表示する tokens/model/contextWindowSize をここに固定保存する。
43
+ * Stop hook が readLatestUsage の結果を載せることで、monitor 側が毎フレーム JSONL を
44
+ * 再スキャンする必要がなくなる。旧バージョン互換のため optional (無ければ monitor が
45
+ * transcriptPath を読んでフォールバック)。
41
46
  */
42
- export function writeSessionState({ sessionId, projectPath, transcriptPath, pid }) {
47
+ export function writeSessionState({ sessionId, projectPath, transcriptPath, pid, usage }) {
43
48
  if (!sessionId) throw new Error('writeSessionState: sessionId is required');
44
49
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
45
50
  const file = join(STATE_DIR, `${sessionId}.json`);
@@ -50,6 +55,7 @@ export function writeSessionState({ sessionId, projectPath, transcriptPath, pid
50
55
  pid: pid ?? process.pid,
51
56
  updatedAt: Date.now(),
52
57
  };
58
+ if (usage) payload.usage = usage;
53
59
  writeFileSync(file, JSON.stringify(payload));
54
60
  }
55
61
 
@@ -133,3 +133,52 @@ test('snapshotStateMtimes: ディレクトリ未作成なら空 Map', async () =
133
133
  assert.equal(snap.size, 0);
134
134
  });
135
135
  });
136
+
137
+ test('writeSessionState: usage 付きで書くと JSON に含まれる', async () => {
138
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
139
+ mod.writeSessionState({
140
+ sessionId: 'sess-a',
141
+ projectPath: '/tmp/x',
142
+ transcriptPath: null,
143
+ pid: 1,
144
+ usage: { tokens: 123, model: 'claude-opus-4-6', contextWindowSize: 200000, outputTokens: 10 },
145
+ });
146
+ const results = mod.readAllSessionStates();
147
+ assert.equal(results.length, 1);
148
+ assert.ok(results[0].usage);
149
+ assert.equal(results[0].usage.tokens, 123);
150
+ assert.equal(results[0].usage.model, 'claude-opus-4-6');
151
+ });
152
+ });
153
+
154
+ test('writeSessionState: usage 無しで書いたらフィールド自体が無い (旧フォーマット互換)', async () => {
155
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
156
+ mod.writeSessionState({
157
+ sessionId: 'sess-b',
158
+ projectPath: '/tmp/x',
159
+ transcriptPath: null,
160
+ pid: 1,
161
+ });
162
+ const results = mod.readAllSessionStates();
163
+ assert.equal(results.length, 1);
164
+ assert.equal(results[0].usage, undefined);
165
+ });
166
+ });
167
+
168
+ test('readAllSessionStates: 旧バージョンが書いた usage 無しの state を読める', async () => {
169
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
170
+ // 旧フォーマット (usage フィールド無し) を直接書く
171
+ const file = join(stateDir, 'old-fmt.json');
172
+ writeFileSync(file, JSON.stringify({
173
+ sessionId: 'old-fmt',
174
+ projectPath: '/tmp/foo',
175
+ transcriptPath: null,
176
+ pid: 1,
177
+ updatedAt: Date.now(),
178
+ }));
179
+ const results = mod.readAllSessionStates();
180
+ assert.equal(results.length, 1);
181
+ assert.equal(results[0].usage, undefined);
182
+ // usage 無しで読めること自体が互換性の証明
183
+ });
184
+ });
@@ -24,6 +24,10 @@ import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjec
24
24
  import { readLatestUsage } from './transcript-usage.mjs';
25
25
 
26
26
  const REFRESH_MS = 1000;
27
+ // データ変化が無くても N ms ごとに再描画して「(24m ago)」表示を進める。
28
+ // 変化検知のほうが優先で、こちらはフォールバック的なタイマー。
29
+ const TIME_AGO_REFRESH_MS = 10_000;
30
+ let lastTimeAgoRefresh = Date.now();
27
31
 
28
32
  // --- ANSI ---
29
33
  const ANSI = {
@@ -198,7 +202,36 @@ function formatNumber(n) {
198
202
  return String(Math.floor(n));
199
203
  }
200
204
 
201
- function formatLine({ state, usage, isActive }) {
205
+ /**
206
+ * ある時刻からの経過時間を短い人間可読形式で返す。
207
+ * 「止まって見える」瞬間に、それがどれだけ前の値なのかを一目で示すために使う。
208
+ * @param {number} ms - 経過ミリ秒
209
+ */
210
+ export function formatTimeAgo(ms) {
211
+ if (!Number.isFinite(ms) || ms < 0) return 'just now';
212
+ const sec = Math.floor(ms / 1000);
213
+ if (sec < 10) return 'just now';
214
+ if (sec < 60) return `${sec}s ago`;
215
+ const min = Math.floor(sec / 60);
216
+ if (min < 60) return `${min}m ago`;
217
+ const hr = Math.floor(min / 60);
218
+ if (hr < 24) return `${hr}h ago`;
219
+ const day = Math.floor(hr / 24);
220
+ return `${day}d ago`;
221
+ }
222
+
223
+ /**
224
+ * columns の変化を検知して全画面再描画すべきかを返す。
225
+ * process.stdout.on('resize') イベントが VSCode 統合ターミナルで発火しないケースが
226
+ * あるため、1 秒 tick から呼び出して polling で検知する。
227
+ * @param {number} prevCols
228
+ * @param {number} currCols
229
+ */
230
+ export function shouldForceFullRedraw(prevCols, currCols) {
231
+ return prevCols !== currCols && currCols > 0;
232
+ }
233
+
234
+ function formatLine({ state, usage, isActive, now = Date.now() }) {
202
235
  const project = basename(state.projectPath || '?');
203
236
  const shortId = state.sessionId.slice(0, 8);
204
237
  const tokens = usage?.tokens ?? 0;
@@ -228,8 +261,13 @@ function formatLine({ state, usage, isActive }) {
228
261
  const tokCol = `${formatNumber(tokens).padStart(6)} / ${pct.toString().padStart(3)}%`;
229
262
  const remCol = color(ANSI.dim, `残 ${formatNumber(remaining)}`);
230
263
  const modelCol = usage?.model ? color(ANSI.dim, usage.model) : color(ANSI.dim, '(未取得)');
264
+ // 最終更新からの経過: 表示が「止まって見える」とき、それが idle なのか障害なのかを
265
+ // 即座に判別できるようにする。updatedAt は state.writeSessionState 時の Date.now()。
266
+ const ago = typeof state.updatedAt === 'number'
267
+ ? color(ANSI.dim, `(${formatTimeAgo(now - state.updatedAt)})`)
268
+ : '';
231
269
 
232
- return `${marker} ${projectCol} ${idCol} ${barCol} ${tokCol} ${remCol} ${modelCol}${warn}`;
270
+ return `${marker} ${projectCol} ${idCol} ${barCol} ${tokCol} ${remCol} ${modelCol} ${ago}${warn}`;
233
271
  }
234
272
 
235
273
  // --- フィルタ ---
@@ -328,10 +366,16 @@ function renderFrame(args) {
328
366
  `[Throughline] ${filtered.length} セッション${args.all ? ' (--all)' : ''}`,
329
367
  );
330
368
  lines.push(header);
369
+ const now = Date.now();
331
370
  for (let i = 0; i < filtered.length; i++) {
332
371
  const state = filtered[i];
333
- const usage = state.transcriptPath ? readLatestUsage(state.transcriptPath) : null;
334
- lines.push(formatLine({ state, usage, isActive: i === 0 }));
372
+ // Stop hook state.usage に固定値を入れていればそれを使う(JSONL 再スキャン不要)。
373
+ // 旧バージョンが書いた state usage スナップショットが取れなかったターンでは
374
+ // transcriptPath を直読してフォールバック。state 側の情報が 1 本化されると
375
+ // 「state が古い JSONL を指している」時の表示ブレが減る。
376
+ const usage = state.usage
377
+ ?? (state.transcriptPath ? readLatestUsage(state.transcriptPath) : null);
378
+ lines.push(formatLine({ state, usage, isActive: i === 0, now }));
335
379
  }
336
380
  }
337
381
 
@@ -393,18 +437,40 @@ export function main() {
393
437
  process.stdout.write(color(ANSI.dim, `[Throughline] モニター起動 (state: ${getStateDir()}, Ctrl+C で終了)\n`));
394
438
 
395
439
  safeRenderFrame(args);
440
+ // columns の最後に使った値。polling で resize 検知するために使う。
441
+ // VSCode 統合ターミナルは process.stdout.on('resize') が発火しないことがあり、
442
+ // 起動時に狭い幅だった場合に描画が崩れたまま固定される(実害ベースで確認済み)。
443
+ // 1 秒 tick のたびに currCols と比較してイベント不達を埋める。
444
+ let lastColumns = process.stdout.columns ?? 0;
396
445
  const timer = setInterval(() => {
446
+ const currCols = process.stdout.columns ?? 0;
447
+ if (shouldForceFullRedraw(lastColumns, currCols)) {
448
+ lastColumns = currCols;
449
+ lastRenderedLines = 0;
450
+ resetRenderKeyCache();
451
+ safeRenderFrame(args);
452
+ return;
453
+ }
454
+ // 状態が更新されていれば再描画。state mtime か transcript size のどちらかが
455
+ // 変わっていれば発火するので、Stop 完了・新 assistant エントリ追記の両方を捕捉する。
397
456
  if (needsRerender()) safeRenderFrame(args);
457
+ // 1 秒刻みの「経過時間」(24m ago など) を反映するため、データに変化が無くても
458
+ // 10 秒に 1 回は強制再描画する。コストは state 数本の JSONL パースのみで軽い。
459
+ if (Date.now() - lastTimeAgoRefresh > TIME_AGO_REFRESH_MS) {
460
+ lastTimeAgoRefresh = Date.now();
461
+ safeRenderFrame(args);
462
+ }
398
463
  }, REFRESH_MS);
399
464
 
400
- // ターミナル幅が変わったら即座に全画面リフレッシュ(前フレームの ANSI 座標が無効化されるため)
401
- // debounce 200ms でドラッグ中のジッタを吸収
465
+ // resize イベント経路は残す: polling 前に通知が来ればより速く反応できる。
466
+ // debounce 200ms でドラッグ中のジッタを吸収し、polling 側との二重再描画も防ぐ。
402
467
  let resizeTimer = null;
403
468
  const onResize = () => {
404
469
  if (resizeTimer) clearTimeout(resizeTimer);
405
470
  resizeTimer = setTimeout(() => {
406
471
  resizeTimer = null;
407
472
  // 既存描画が新しい幅では崩れている可能性があるため座標情報を破棄して再描画
473
+ lastColumns = process.stdout.columns ?? 0;
408
474
  lastRenderedLines = 0;
409
475
  resetRenderKeyCache();
410
476
  safeRenderFrame(args);
@@ -452,6 +518,8 @@ export const _internal = {
452
518
  computeRenderKey,
453
519
  needsRerender,
454
520
  resetRenderKeyCache,
521
+ formatTimeAgo,
522
+ shouldForceFullRedraw,
455
523
  };
456
524
 
457
525
  // --- エントリポイント自動起動 ---
@@ -13,6 +13,8 @@ const {
13
13
  formatNumber,
14
14
  renderBar,
15
15
  formatLine,
16
+ formatTimeAgo,
17
+ shouldForceFullRedraw,
16
18
  } = _internal;
17
19
 
18
20
  // state-file は projectPath を resolve + lowercase 正規化する。
@@ -329,3 +331,76 @@ test('formatLine: プロジェクト名に CJK が含まれてもセル幅で整
329
331
  // のは難しいがクラッシュしないことと想定文字列が含まれることを最低限確認
330
332
  assert.ok(stripColors(out).includes('プロジェクト名'));
331
333
  });
334
+
335
+ // ─── formatTimeAgo ─────────────────────────────────────────────────
336
+
337
+ test('formatTimeAgo: 10 秒未満は "just now"', () => {
338
+ assert.equal(formatTimeAgo(0), 'just now');
339
+ assert.equal(formatTimeAgo(500), 'just now');
340
+ assert.equal(formatTimeAgo(9_500), 'just now');
341
+ });
342
+
343
+ test('formatTimeAgo: 60 秒未満は秒表示', () => {
344
+ assert.equal(formatTimeAgo(15_000), '15s ago');
345
+ assert.equal(formatTimeAgo(59_000), '59s ago');
346
+ });
347
+
348
+ test('formatTimeAgo: 60 分未満は分表示', () => {
349
+ assert.equal(formatTimeAgo(60_000), '1m ago');
350
+ assert.equal(formatTimeAgo(24 * 60 * 1000), '24m ago');
351
+ });
352
+
353
+ test('formatTimeAgo: 24 時間未満は時表示', () => {
354
+ assert.equal(formatTimeAgo(60 * 60 * 1000), '1h ago');
355
+ assert.equal(formatTimeAgo(23 * 60 * 60 * 1000), '23h ago');
356
+ });
357
+
358
+ test('formatTimeAgo: 24 時間以上は日表示', () => {
359
+ assert.equal(formatTimeAgo(24 * 60 * 60 * 1000), '1d ago');
360
+ assert.equal(formatTimeAgo(3 * 24 * 60 * 60 * 1000), '3d ago');
361
+ });
362
+
363
+ test('formatTimeAgo: 無効値は "just now"', () => {
364
+ assert.equal(formatTimeAgo(NaN), 'just now');
365
+ assert.equal(formatTimeAgo(-1), 'just now');
366
+ assert.equal(formatTimeAgo(Infinity), 'just now');
367
+ });
368
+
369
+ // ─── shouldForceFullRedraw ────────────────────────────────────────
370
+
371
+ test('shouldForceFullRedraw: columns 変化なしは false', () => {
372
+ assert.equal(shouldForceFullRedraw(80, 80), false);
373
+ });
374
+
375
+ test('shouldForceFullRedraw: columns が増えたら true', () => {
376
+ assert.equal(shouldForceFullRedraw(40, 120), true);
377
+ });
378
+
379
+ test('shouldForceFullRedraw: columns が減ったら true', () => {
380
+ assert.equal(shouldForceFullRedraw(120, 40), true);
381
+ });
382
+
383
+ test('shouldForceFullRedraw: 片方 0 以下は false (未初期化 or 無効)', () => {
384
+ assert.equal(shouldForceFullRedraw(80, 0), false);
385
+ assert.equal(shouldForceFullRedraw(0, 80), true);
386
+ });
387
+
388
+ // ─── formatLine: time-ago 表示 ────────────────────────────────────
389
+
390
+ test('formatLine: updatedAt から経過時間が表示される', () => {
391
+ const now = Date.now();
392
+ const args = {
393
+ ...makeLineArgs(0.5),
394
+ now,
395
+ };
396
+ args.state.updatedAt = now - 3 * 60 * 1000; // 3 分前
397
+ const out = stripColors(formatLine(args));
398
+ assert.ok(out.includes('(3m ago)'), `expected "(3m ago)" in output: ${out}`);
399
+ });
400
+
401
+ test('formatLine: updatedAt が無ければ ago 表示は出ない', () => {
402
+ const args = makeLineArgs(0.5);
403
+ delete args.state.updatedAt;
404
+ const out = stripColors(formatLine(args));
405
+ assert.ok(!out.includes('ago'));
406
+ });
@@ -43,6 +43,7 @@ import { resolveMergeTarget } from './session-merger.mjs';
43
43
  import { writeSessionState } from './state-file.mjs';
44
44
  import { summarizeToL1 } from './haiku-summarizer.mjs';
45
45
  import { ensureMonitorTaskFile } from './vscode-task.mjs';
46
+ import { readLatestUsage } from './transcript-usage.mjs';
46
47
 
47
48
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
48
49
  export const L2_WINDOW = 20;
@@ -273,6 +274,26 @@ async function main() {
273
274
  }
274
275
  }
275
276
 
277
+ // monitor が JSONL を毎フレーム再スキャンせずに済むよう、現在確定している usage を
278
+ // state ファイルに固定する。Stop 完了時点で assistant エントリは transcript に
279
+ // 書き出し済みなので readLatestUsage が最新値を返す。
280
+ // 取得失敗は致命ではないので try/catch で握る(stderr には出す)。
281
+ try {
282
+ const usage = transcript_path ? readLatestUsage(transcript_path) : null;
283
+ if (usage) {
284
+ writeSessionState({
285
+ sessionId: session_id,
286
+ projectPath: cwd ?? process.cwd(),
287
+ transcriptPath: transcript_path ?? null,
288
+ pid: process.ppid,
289
+ usage,
290
+ });
291
+ }
292
+ } catch (err) {
293
+ const msg = err instanceof Error ? err.message : 'unknown';
294
+ process.stderr.write(`[turn-processor] usage snapshot failed: ${msg}\n`);
295
+ }
296
+
276
297
  process.exit(0);
277
298
  }
278
299