throughline 0.3.0 → 0.3.2

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.
@@ -36,7 +36,7 @@ switch (cmd) {
36
36
  await import('../src/prompt-submit.mjs');
37
37
  break;
38
38
  case 'monitor':
39
- await import('../src/token-monitor.mjs');
39
+ (await import('../src/token-monitor.mjs')).main();
40
40
  break;
41
41
  case 'detail':
42
42
  (await import('../src/sc-detail.mjs')).run(rest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -68,25 +68,52 @@ export const STALE_DELETE_MS = 24 * 60 * 60 * 1000; // 24 時間: ファイル
68
68
  export function readAllSessionStates() {
69
69
  if (!existsSync(STATE_DIR)) return [];
70
70
  const now = Date.now();
71
- const entries = readdirSync(STATE_DIR);
71
+ let entries;
72
+ try {
73
+ entries = readdirSync(STATE_DIR);
74
+ } catch (err) {
75
+ if (err.code === 'ENOENT') return [];
76
+ process.stderr.write(`[state-file] readdir failed (${err.code ?? 'unknown'}): ${err.message}\n`);
77
+ return [];
78
+ }
72
79
  const results = [];
73
80
  for (const name of entries) {
74
81
  if (!name.endsWith('.json')) continue;
75
82
  const file = join(STATE_DIR, name);
76
- const raw = readFileSync(file, 'utf8');
83
+
84
+ // 常駐 monitor を落とさないため IO 例外は吸収する。
85
+ // ENOENT: 削除 race、EACCES/EPERM: 一時的権限問題、いずれも skip して次フレームで再試行
86
+ let raw;
87
+ try {
88
+ raw = readFileSync(file, 'utf8');
89
+ } catch (err) {
90
+ if (err.code !== 'ENOENT') {
91
+ process.stderr.write(`[state-file] read failed ${name} (${err.code ?? 'unknown'}): ${err.message}\n`);
92
+ }
93
+ continue;
94
+ }
95
+
77
96
  let parsed;
78
97
  try {
79
98
  parsed = JSON.parse(raw);
80
99
  } catch (err) {
81
100
  // JSON 破損は状態ファイル固有の冪等な状況(次ターンで再生成される)
82
101
  process.stderr.write(`[state-file] corrupt state file ${name}, deleting: ${err.message}\n`);
83
- unlinkSync(file);
102
+ try {
103
+ unlinkSync(file);
104
+ } catch {
105
+ // 削除失敗も握りつぶす(次回削除される)
106
+ }
84
107
  continue;
85
108
  }
86
109
  const age = now - (parsed.updatedAt ?? 0);
87
110
  if (age > STALE_DELETE_MS) {
88
111
  // 24h 超: ハード削除(無制限蓄積防止)
89
- unlinkSync(file);
112
+ try {
113
+ unlinkSync(file);
114
+ } catch {
115
+ // 削除失敗は致命ではない、次回再試行
116
+ }
90
117
  continue;
91
118
  }
92
119
  parsed.stale = age > STALE_HIDE_MS;
@@ -102,15 +129,25 @@ export function readAllSessionStates() {
102
129
  export function snapshotStateMtimes() {
103
130
  const result = new Map();
104
131
  if (!existsSync(STATE_DIR)) return result;
105
- for (const name of readdirSync(STATE_DIR)) {
132
+ let entries;
133
+ try {
134
+ entries = readdirSync(STATE_DIR);
135
+ } catch (err) {
136
+ if (err.code === 'ENOENT') return result;
137
+ process.stderr.write(`[state-file] snapshot readdir failed (${err.code ?? 'unknown'}): ${err.message}\n`);
138
+ return result;
139
+ }
140
+ for (const name of entries) {
106
141
  if (!name.endsWith('.json')) continue;
107
142
  // readdir と stat の間でファイルが削除される race がある。
108
- // ENOENT のみ許容してそれ以外は throw(§0)
143
+ // monitor を落とさないため ENOENT 以外の IO 例外も吸収する(EACCES/EPERM 等は一時的で次フレームで回復)
109
144
  const file = join(STATE_DIR, name);
110
145
  try {
111
146
  result.set(name, statSync(file).mtimeMs);
112
147
  } catch (err) {
113
- if (err.code !== 'ENOENT') throw err;
148
+ if (err.code !== 'ENOENT') {
149
+ process.stderr.write(`[state-file] stat failed ${name} (${err.code ?? 'unknown'}): ${err.message}\n`);
150
+ }
114
151
  }
115
152
  }
116
153
  return result;
@@ -0,0 +1,135 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, chmodSync, existsSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir, homedir, platform } from 'node:os';
6
+
7
+ import { normalizeProjectPath } from './state-file.mjs';
8
+
9
+ // state-file.mjs は ~/.throughline/state を直接見るので、モジュール全体を一時ディレクトリ指定で
10
+ // 差し替える薄いヘルパーを用意する。HOME 環境変数を偽装して import し直す方式で隔離する。
11
+ async function withIsolatedStateDir(testFn) {
12
+ const work = mkdtempSync(join(tmpdir(), 'tl-state-'));
13
+ const origHome = process.env.HOME;
14
+ const origUserProfile = process.env.USERPROFILE;
15
+ process.env.HOME = work;
16
+ process.env.USERPROFILE = work;
17
+ // ESM キャッシュをバイパスするため query string 付きで import
18
+ const mod = await import(`./state-file.mjs?isolated=${Date.now()}-${Math.random()}`);
19
+ const stateDir = mod.getStateDir();
20
+ mkdirSync(stateDir, { recursive: true });
21
+ try {
22
+ await testFn({ stateDir, mod });
23
+ } finally {
24
+ process.env.HOME = origHome;
25
+ process.env.USERPROFILE = origUserProfile;
26
+ rmSync(work, { recursive: true, force: true });
27
+ }
28
+ }
29
+
30
+ test('normalizeProjectPath: 空文字列は空文字列', () => {
31
+ assert.equal(normalizeProjectPath(''), '');
32
+ assert.equal(normalizeProjectPath(null), '');
33
+ assert.equal(normalizeProjectPath(undefined), '');
34
+ });
35
+
36
+ test('normalizeProjectPath: バックスラッシュがスラッシュになり末尾スラッシュ除去', () => {
37
+ const result = normalizeProjectPath('C:\\Users\\foo\\');
38
+ assert.ok(result.includes('/'));
39
+ assert.ok(!result.endsWith('/') || result === '/');
40
+ });
41
+
42
+ test('normalizeProjectPath: Windows では lowercase', () => {
43
+ if (platform() !== 'win32') return;
44
+ const result = normalizeProjectPath('C:\\Users\\Foo');
45
+ assert.equal(result, result.toLowerCase());
46
+ });
47
+
48
+ test('readAllSessionStates: 破損 JSON を削除して skip する', async () => {
49
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
50
+ const broken = join(stateDir, 'broken-abc.json');
51
+ const good = join(stateDir, 'good-session.json');
52
+ writeFileSync(broken, '{ not valid json');
53
+ writeFileSync(good, JSON.stringify({
54
+ sessionId: 'good-session',
55
+ projectPath: '/tmp/foo',
56
+ transcriptPath: null,
57
+ updatedAt: Date.now(),
58
+ }));
59
+ const results = mod.readAllSessionStates();
60
+ assert.equal(results.length, 1);
61
+ assert.equal(results[0].sessionId, 'good-session');
62
+ // 破損ファイルは削除されている
63
+ assert.ok(!existsSync(broken), 'corrupt file should be unlinked');
64
+ });
65
+ });
66
+
67
+ test('readAllSessionStates: 24h 超のファイルはハード削除される', async () => {
68
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
69
+ const old = join(stateDir, 'old-session.json');
70
+ const past = Date.now() - (25 * 60 * 60 * 1000); // 25h 前
71
+ writeFileSync(old, JSON.stringify({
72
+ sessionId: 'old-session',
73
+ projectPath: '/tmp/foo',
74
+ transcriptPath: null,
75
+ updatedAt: past,
76
+ }));
77
+ const results = mod.readAllSessionStates();
78
+ assert.equal(results.length, 0);
79
+ assert.ok(!existsSync(old), 'old file should be deleted');
80
+ });
81
+ });
82
+
83
+ test('readAllSessionStates: 15 分超は stale フラグ付きで返される', async () => {
84
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
85
+ const stale = join(stateDir, 'stale-session.json');
86
+ const past = Date.now() - (20 * 60 * 1000); // 20 分前
87
+ writeFileSync(stale, JSON.stringify({
88
+ sessionId: 'stale-session',
89
+ projectPath: '/tmp/foo',
90
+ transcriptPath: null,
91
+ updatedAt: past,
92
+ }));
93
+ const results = mod.readAllSessionStates();
94
+ assert.equal(results.length, 1);
95
+ assert.equal(results[0].stale, true);
96
+ });
97
+ });
98
+
99
+ test('readAllSessionStates: state ディレクトリ未作成なら空配列', async () => {
100
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
101
+ rmSync(stateDir, { recursive: true, force: true });
102
+ const results = mod.readAllSessionStates();
103
+ assert.deepEqual(results, []);
104
+ });
105
+ });
106
+
107
+ test('snapshotStateMtimes: 存在する JSON の mtime を返す', async () => {
108
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
109
+ const file = join(stateDir, 'abc.json');
110
+ writeFileSync(file, '{}');
111
+ const snap = mod.snapshotStateMtimes();
112
+ assert.equal(snap.size, 1);
113
+ assert.ok(snap.has('abc.json'));
114
+ assert.ok(typeof snap.get('abc.json') === 'number');
115
+ });
116
+ });
117
+
118
+ test('snapshotStateMtimes: .json 以外は無視', async () => {
119
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
120
+ writeFileSync(join(stateDir, 'ignored.txt'), 'hello');
121
+ writeFileSync(join(stateDir, 'abc.json'), '{}');
122
+ const snap = mod.snapshotStateMtimes();
123
+ assert.equal(snap.size, 1);
124
+ assert.ok(snap.has('abc.json'));
125
+ assert.ok(!snap.has('ignored.txt'));
126
+ });
127
+ });
128
+
129
+ test('snapshotStateMtimes: ディレクトリ未作成なら空 Map', async () => {
130
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
131
+ rmSync(stateDir, { recursive: true, force: true });
132
+ const snap = mod.snapshotStateMtimes();
133
+ assert.equal(snap.size, 0);
134
+ });
135
+ });
@@ -18,9 +18,10 @@
18
18
  */
19
19
 
20
20
  import { basename } from 'node:path';
21
+ import { stripVTControlCharacters } from 'node:util';
22
+ import { statSync, existsSync } from 'node:fs';
21
23
  import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjectPath } from './state-file.mjs';
22
24
  import { readLatestUsage } from './transcript-usage.mjs';
23
- import { stripAnsi } from './transcript-reader.mjs';
24
25
 
25
26
  const REFRESH_MS = 1000;
26
27
 
@@ -45,60 +46,156 @@ function color(c, text) {
45
46
  return `${c}${text}${ANSI.reset}`;
46
47
  }
47
48
 
48
- /** ANSI エスケープシーケンスを除いた可視文字数を返す(サロゲートペア考慮はしない簡易版) */
49
- function visibleLength(s) {
50
- return stripAnsi(s).length;
49
+ // --- セル幅計算(ANSI / CJK / 絵文字対応、依存ゼロ) ---
50
+ //
51
+ // Node v22 には util.getStringWidth がないため、主要な East Asian Wide + 絵文字ブロックを
52
+ // 自前の範囲判定で 2 セルとして扱う。East Asian Ambiguous は 1 セル(アラビア・タイ等は
53
+ // 既存の char-count 実装と同じ扱いなので悪化しない)。
54
+ // ZWJ / VS16 は幅 0 として扱う(絵文字シーケンスの微ズレは許容)。
55
+ const ZERO_WIDTH_CODEPOINTS = new Set([0x200b, 0x200c, 0x200d, 0xfeff, 0xfe0e, 0xfe0f]);
56
+
57
+ /**
58
+ * 単一コードポイントのセル幅を返す(0 / 1 / 2)
59
+ * @param {number} cp
60
+ */
61
+ function codePointWidth(cp) {
62
+ if (cp < 0x20) return 0; // C0 制御
63
+ if (cp >= 0x7f && cp < 0xa0) return 0; // DEL / C1 制御
64
+ if (ZERO_WIDTH_CODEPOINTS.has(cp)) return 0;
65
+ // Combining marks (簡易: Combining Diacritical Marks 主要ブロック)
66
+ if (cp >= 0x0300 && cp <= 0x036f) return 0;
67
+ // Wide ranges
68
+ if (
69
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
70
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals / Kangxi
71
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana/Katakana/Bopomofo/CJK Symbols
72
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
73
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
74
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
75
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
76
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
77
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
78
+ (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth
79
+ (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
80
+ (cp >= 0x1f300 && cp <= 0x1faff) || // 絵文字主要ブロック
81
+ (cp >= 0x20000 && cp <= 0x3134f) // CJK Ext B-G
82
+ ) {
83
+ return 2;
84
+ }
85
+ return 1;
86
+ }
87
+
88
+ /**
89
+ * 文字列のセル幅合計(ANSI 剥ぎ取り済み前提ではない — 内部で剥ぐ)
90
+ * @param {string} s
91
+ */
92
+ function cellWidth(s) {
93
+ if (typeof s !== 'string') return 0;
94
+ const stripped = stripVTControlCharacters(s);
95
+ let total = 0;
96
+ for (const ch of stripped) {
97
+ total += codePointWidth(ch.codePointAt(0));
98
+ }
99
+ return total;
51
100
  }
52
101
 
53
102
  /**
54
- * 行をターミナル幅に収まるよう切り詰める。ANSI コードを壊さないため、
55
- * 可視文字だけを数えながらコピーし、上限に達したら reset を付けて返す。
103
+ * 行をセル幅で切り詰める。ANSI コードはそのまま通過させ(幅 0)、
104
+ * 可視セル幅が maxCells に達したら残りを捨てて reset を付けて返す。
105
+ * CJK 文字を跨ぐときは 1 セル余る場合があるので空白で埋める。
56
106
  * @param {string} line
57
- * @param {number} maxWidth
107
+ * @param {number} maxCells
58
108
  */
59
- function truncateToWidth(line, maxWidth) {
60
- if (maxWidth <= 0) return '';
61
- if (visibleLength(line) <= maxWidth) return line;
109
+ function truncateToCells(line, maxCells) {
110
+ if (maxCells <= 0) return '';
111
+ if (cellWidth(line) <= maxCells) return line;
62
112
  let out = '';
63
- let visible = 0;
113
+ let cells = 0;
64
114
  let i = 0;
65
- while (i < line.length && visible < maxWidth) {
66
- const ch = line[i];
67
- if (ch === '\x1b' && line[i + 1] === '[') {
68
- // ANSI sequence: copy until final byte (a-zA-Z)
69
- const end = line.slice(i).search(/[a-zA-Z]/);
70
- if (end === -1) break;
71
- out += line.slice(i, i + end + 1);
72
- i += end + 1;
115
+ while (i < line.length) {
116
+ const code = line.charCodeAt(i);
117
+ // ANSI CSI: \x1b[ ... 終端 (0x40-0x7e)
118
+ if (code === 0x1b && line.charCodeAt(i + 1) === 0x5b) {
119
+ let j = i + 2;
120
+ while (j < line.length) {
121
+ const c = line.charCodeAt(j);
122
+ if (c >= 0x40 && c <= 0x7e) { j++; break; }
123
+ j++;
124
+ }
125
+ out += line.slice(i, j);
126
+ i = j;
73
127
  continue;
74
128
  }
129
+ // コードポイント単位で取得(サロゲートペア考慮)
130
+ const cp = line.codePointAt(i);
131
+ const ch = String.fromCodePoint(cp);
132
+ const w = codePointWidth(cp);
133
+ if (cells + w > maxCells) {
134
+ // CJK 1 セル余り
135
+ if (cells < maxCells) out += ' ';
136
+ break;
137
+ }
75
138
  out += ch;
76
- visible++;
77
- i++;
139
+ cells += w;
140
+ i += ch.length;
78
141
  }
79
142
  return out + ANSI.reset;
80
143
  }
81
144
 
145
+ /**
146
+ * 文字列の末尾を半角スペースでパディングして targetCells に揃える。
147
+ * 既にはみ出している場合は truncateToCells で切り詰める。
148
+ * @param {string} s
149
+ * @param {number} targetCells
150
+ */
151
+ function padCellsEnd(s, targetCells) {
152
+ const width = cellWidth(s);
153
+ if (width === targetCells) return s;
154
+ if (width > targetCells) return truncateToCells(s, targetCells);
155
+ return s + ' '.repeat(targetCells - width);
156
+ }
157
+
82
158
  // --- CLI 引数 ---
159
+ /**
160
+ * @param {string[]} argv
161
+ * @returns {{all: boolean, session: string|null}}
162
+ * @throws {Error} --session に値が欠落している場合
163
+ */
83
164
  function parseArgs(argv) {
84
165
  const args = { all: false, session: null };
85
166
  for (let i = 0; i < argv.length; i++) {
86
- if (argv[i] === '--all') args.all = true;
87
- else if (argv[i] === '--session') args.session = argv[++i];
167
+ if (argv[i] === '--all') {
168
+ args.all = true;
169
+ } else if (argv[i] === '--session') {
170
+ const value = argv[i + 1];
171
+ if (value === undefined || value.startsWith('--')) {
172
+ throw new Error('--session requires a session id (or prefix) as next argument');
173
+ }
174
+ args.session = value;
175
+ i++;
176
+ }
88
177
  }
89
178
  return args;
90
179
  }
91
180
 
92
181
  // --- 表示 ---
93
182
  function renderBar(ratio, width = 20) {
94
- const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
183
+ // NaN 0、+Infinity 1(オーバーフロー = 満タン表示)、負値 / -Infinity は 0 にクランプ
184
+ let safe;
185
+ if (Number.isNaN(ratio)) safe = 0;
186
+ else if (ratio === Infinity) safe = 1;
187
+ else if (ratio === -Infinity) safe = 0;
188
+ else safe = Math.max(0, Math.min(1, ratio));
189
+ const filled = Math.round(safe * width);
95
190
  return '█'.repeat(filled) + '░'.repeat(width - filled);
96
191
  }
97
192
 
98
193
  function formatNumber(n) {
99
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
194
+ if (!Number.isFinite(n) || n < 0) return '0';
195
+ // 999_950 以上は toFixed(1) で "1000.0k" になってしまうので M 表記に切り上げる
196
+ if (n >= 999_500) return (n / 1_000_000).toFixed(2) + 'M';
100
197
  if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
101
- return String(n);
198
+ return String(Math.floor(n));
102
199
  }
103
200
 
104
201
  function formatLine({ state, usage, isActive }) {
@@ -116,13 +213,16 @@ function formatLine({ state, usage, isActive }) {
116
213
  ratio >= 0.7 ? ANSI.yellow :
117
214
  ANSI.green;
118
215
 
216
+ // 色覚配慮: 色だけでなく記号 / テキスト接頭辞でも重要度を示す
217
+ // 90%超: !! + 強い文言 (赤)
218
+ // 70%超: ! + 弱い文言 (黄)
119
219
  const warn =
120
- ratio >= 0.9 ? color(ANSI.red, ' /clear 強く推奨') :
121
- ratio >= 0.7 ? color(ANSI.yellow, ' そろそろ /clear') :
220
+ ratio >= 0.9 ? color(ANSI.red + ANSI.bold, ' !! /clear 強く推奨') :
221
+ ratio >= 0.7 ? color(ANSI.yellow, ' ! そろそろ /clear') :
122
222
  '';
123
223
 
124
224
  const marker = isActive ? color(ANSI.bold + ANSI.cyan, '▶') : ' ';
125
- const projectCol = project.padEnd(18).slice(0, 18);
225
+ const projectCol = padCellsEnd(project, 18);
126
226
  const idCol = color(ANSI.dim, shortId);
127
227
  const barCol = color(barColor, bar);
128
228
  const tokCol = `${formatNumber(tokens).padStart(6)} / ${pct.toString().padStart(3)}%`;
@@ -133,11 +233,18 @@ function formatLine({ state, usage, isActive }) {
133
233
  }
134
234
 
135
235
  // --- フィルタ ---
236
+ /**
237
+ * セッション一覧に表示フィルタを適用する。
238
+ * ルール:
239
+ * - stale (15 分無活動) は基本非表示。--all のときのみ stale も含める
240
+ * - --session が指定されればプレフィックス一致、ただし base (stale フィルタ通過済み) 上で絞る
241
+ * - --session 無し & --all 無しのときは cwd 一致のみ残す
242
+ */
136
243
  function filterStates(states, args, cwd) {
137
- // stale (15 分無活動) は基本非表示。--all のときだけ stale も含める
138
- let base = args.all ? states : states.filter((s) => !s.stale);
244
+ const base = args.all ? states : states.filter((s) => !s.stale);
139
245
  if (args.session) {
140
- return states.filter((s) => s.sessionId === args.session || s.sessionId.startsWith(args.session));
246
+ // startsWith が完全一致も含むので === は冗長
247
+ return base.filter((s) => s.sessionId.startsWith(args.session));
141
248
  }
142
249
  if (args.all) return base;
143
250
  const normalizedCwd = normalizeProjectPath(cwd);
@@ -146,24 +253,55 @@ function filterStates(states, args, cwd) {
146
253
 
147
254
  // --- メインループ ---
148
255
  let lastRenderedLines = 0;
149
- let lastMtimes = new Map();
256
+ let lastRenderKey = '';
150
257
 
258
+ /**
259
+ * 再描画要否の判定キー。state ファイル群の mtime と transcript JSONL の size を
260
+ * 1 本の文字列にまとめてハッシュキーとする。キーが前回と同じなら描画スキップ。
261
+ *
262
+ * 注: transcript は JSONL append-only なので size 変化 = 新しい usage エントリ到来と
263
+ * 同義。mtime だけでは transcript 更新を検出できない(state-file の mtime は
264
+ * Stop hook のタイミングで更新され、transcript は Claude の stream 中に太る)。
265
+ */
266
+ function computeRenderKey() {
267
+ const parts = [];
268
+ // state mtimes
269
+ const mtimes = snapshotStateMtimes();
270
+ const names = Array.from(mtimes.keys()).sort();
271
+ for (const name of names) parts.push(`s:${name}:${mtimes.get(name)}`);
272
+ // transcript sizes(state ファイルを読まずに直接 stat、IO 最小化)
273
+ try {
274
+ const states = readAllSessionStates();
275
+ for (const st of states) {
276
+ if (!st.transcriptPath || !existsSync(st.transcriptPath)) continue;
277
+ try {
278
+ const size = statSync(st.transcriptPath).size;
279
+ parts.push(`t:${st.sessionId}:${size}`);
280
+ } catch {
281
+ // stat 失敗は無視(次フレームで回復)
282
+ }
283
+ }
284
+ } catch {
285
+ // readAllSessionStates 自体の失敗も 1 フレームで回復
286
+ }
287
+ return parts.join('|');
288
+ }
289
+
290
+ /**
291
+ * 前回と比べてキーが変化していれば true。副作用として lastRenderKey を更新する。
292
+ */
151
293
  function needsRerender() {
152
- const current = snapshotStateMtimes();
153
- if (current.size !== lastMtimes.size) {
154
- lastMtimes = current;
294
+ const key = computeRenderKey();
295
+ if (key !== lastRenderKey) {
296
+ lastRenderKey = key;
155
297
  return true;
156
298
  }
157
- for (const [name, mtime] of current) {
158
- if (lastMtimes.get(name) !== mtime) {
159
- lastMtimes = current;
160
- return true;
161
- }
162
- }
163
- // mtime は同じでも transcript JSONL のサイズが変われば再描画したい
164
- // → transcript-usage のキャッシュ判定に任せるため毎秒呼ぶ設計。
165
- // state ファイル変化なしでも再計算は走らせる(キャッシュヒット時は軽量)
166
- return true;
299
+ return false;
300
+ }
301
+
302
+ /** テスト用リセット */
303
+ function resetRenderKeyCache() {
304
+ lastRenderKey = '';
167
305
  }
168
306
 
169
307
  function renderFrame(args) {
@@ -174,9 +312,15 @@ function renderFrame(args) {
174
312
 
175
313
  const lines = [];
176
314
  if (filtered.length === 0) {
177
- lines.push(color(ANSI.dim, '[Throughline] 待機中 — アクティブなセッションがありません'));
178
- if (!args.all) {
179
- lines.push(color(ANSI.dim, ` (${normalizeProjectPath(process.cwd())} state 無し。--all で全プロジェクト表示)`));
315
+ if (args.session) {
316
+ // --session 指定で未ヒットは「存在しない」と明示(一般の "待機中" と区別)
317
+ lines.push(color(ANSI.yellow, `[Throughline] セッション "${args.session}" に一致する active セッションが見つかりません`));
318
+ lines.push(color(ANSI.dim, ' (プレフィックス指定も可。--all で stale を含めて再検索)'));
319
+ } else {
320
+ lines.push(color(ANSI.dim, '[Throughline] 待機中 — アクティブなセッションがありません'));
321
+ if (!args.all) {
322
+ lines.push(color(ANSI.dim, ` (${normalizeProjectPath(process.cwd())} に state 無し。--all で全プロジェクト表示)`));
323
+ }
180
324
  }
181
325
  } else {
182
326
  const header = color(
@@ -191,13 +335,14 @@ function renderFrame(args) {
191
335
  }
192
336
  }
193
337
 
194
- // ★ 折り返し対策: 各行を (columns - 1) 幅に切り詰めて物理 1 行に収める。
338
+ // ★ 折り返し対策: 各行を (columns - 1) セル幅に切り詰めて物理 1 行に収める。
195
339
  // こうすれば ANSI.up(lines.length) と論理行数が物理行数と一致する。
196
340
  // columns - 1 にしてるのはターミナル末尾列に書くと自動改行する端末があるため。
341
+ // truncateToCells は CJK / 絵文字を 2 セルとして正しく数える。
197
342
  const columns = process.stdout.columns && process.stdout.columns > 10
198
343
  ? process.stdout.columns - 1
199
344
  : 120;
200
- const clipped = lines.map((l) => truncateToWidth(l, columns));
345
+ const clipped = lines.map((l) => truncateToCells(l, columns));
201
346
 
202
347
  // 前フレームを消去してから再描画:
203
348
  // 1. カーソルを前フレームの先頭行へ戻す (CUU = 行移動のみ)
@@ -213,25 +358,112 @@ function renderFrame(args) {
213
358
  }
214
359
 
215
360
  // --- 起動 ---
216
- function main() {
217
- const args = parseArgs(process.argv.slice(2));
361
+ let cursorRestored = false;
362
+ function restoreCursor() {
363
+ if (cursorRestored) return;
364
+ cursorRestored = true;
365
+ try {
366
+ process.stdout.write(ANSI.showCursor);
367
+ } catch {
368
+ // stdout がすでに閉じていても無視
369
+ }
370
+ }
371
+
372
+ function safeRenderFrame(args) {
373
+ try {
374
+ renderFrame(args);
375
+ } catch (err) {
376
+ const msg = err instanceof Error ? err.message : 'unknown error';
377
+ process.stderr.write(`[Throughline] render error: ${msg}\n`);
378
+ // 1 フレームの失敗で常駐を落とさない。次フレームで回復を試す
379
+ }
380
+ }
381
+
382
+ export function main() {
383
+ let args;
384
+ try {
385
+ args = parseArgs(process.argv.slice(2));
386
+ } catch (err) {
387
+ const msg = err instanceof Error ? err.message : 'invalid args';
388
+ process.stderr.write(`[Throughline] ${msg}\n`);
389
+ process.exit(2);
390
+ }
218
391
 
219
392
  process.stdout.write(ANSI.hideCursor);
220
393
  process.stdout.write(color(ANSI.dim, `[Throughline] モニター起動 (state: ${getStateDir()}, Ctrl+C で終了)\n`));
221
394
 
222
- renderFrame(args);
395
+ safeRenderFrame(args);
223
396
  const timer = setInterval(() => {
224
- if (needsRerender()) renderFrame(args);
397
+ if (needsRerender()) safeRenderFrame(args);
225
398
  }, REFRESH_MS);
226
399
 
227
- const shutdown = () => {
400
+ // ターミナル幅が変わったら即座に全画面リフレッシュ(前フレームの ANSI 座標が無効化されるため)
401
+ // debounce 200ms でドラッグ中のジッタを吸収
402
+ let resizeTimer = null;
403
+ const onResize = () => {
404
+ if (resizeTimer) clearTimeout(resizeTimer);
405
+ resizeTimer = setTimeout(() => {
406
+ resizeTimer = null;
407
+ // 既存描画が新しい幅では崩れている可能性があるため座標情報を破棄して再描画
408
+ lastRenderedLines = 0;
409
+ resetRenderKeyCache();
410
+ safeRenderFrame(args);
411
+ }, 200);
412
+ };
413
+ if (typeof process.stdout.on === 'function') {
414
+ process.stdout.on('resize', onResize);
415
+ }
416
+
417
+ const shutdown = (code = 0) => {
228
418
  clearInterval(timer);
229
- process.stdout.write('\n' + ANSI.showCursor);
230
- process.stdout.write(color(ANSI.dim, '[Throughline] モニター終了\n'));
231
- process.exit(0);
419
+ if (resizeTimer) clearTimeout(resizeTimer);
420
+ restoreCursor();
421
+ process.stdout.write('\n' + color(ANSI.dim, '[Throughline] モニター終了\n'));
422
+ process.exit(code);
232
423
  };
233
- process.on('SIGINT', shutdown);
234
- process.on('SIGTERM', shutdown);
424
+ process.on('SIGINT', () => shutdown(0));
425
+ process.on('SIGTERM', () => shutdown(0));
426
+
427
+ // クラッシュ時もカーソルを必ず戻す
428
+ process.on('exit', restoreCursor);
429
+ process.on('uncaughtException', (err) => {
430
+ restoreCursor();
431
+ const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
432
+ process.stderr.write(`[Throughline] uncaught exception:\n${msg}\n`);
433
+ process.exit(1);
434
+ });
435
+ process.on('unhandledRejection', (reason) => {
436
+ restoreCursor();
437
+ process.stderr.write(`[Throughline] unhandled rejection: ${String(reason)}\n`);
438
+ process.exit(1);
439
+ });
235
440
  }
236
441
 
237
- main();
442
+ // --- テスト用エクスポート(本番コードからは参照しない) ---
443
+ export const _internal = {
444
+ parseArgs,
445
+ filterStates,
446
+ cellWidth,
447
+ truncateToCells,
448
+ padCellsEnd,
449
+ formatNumber,
450
+ renderBar,
451
+ formatLine,
452
+ computeRenderKey,
453
+ needsRerender,
454
+ resetRenderKeyCache,
455
+ };
456
+
457
+ // --- エントリポイント自動起動 ---
458
+ // `node src/token-monitor.mjs` 直接起動(VSCode タスク .vscode/tasks.json が使うパターン)
459
+ // のとき main() を自動実行する。
460
+ // dispatcher 経由(`throughline monitor`)は bin/throughline.mjs が明示的に main() を呼ぶ。
461
+ // テスト import(`node --test src/token-monitor.test.mjs`)は argv[1] が `.test.mjs` で終わる。
462
+ // argv[1] が 'token-monitor.mjs' で終わって '.test.mjs' でないケースだけ auto-run する。
463
+ {
464
+ const argv1 = (process.argv[1] || '').replace(/\\/g, '/');
465
+ const isDirectEntry = argv1.endsWith('token-monitor.mjs') && !argv1.endsWith('.test.mjs');
466
+ if (isDirectEntry) {
467
+ main();
468
+ }
469
+ }
@@ -0,0 +1,331 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { _internal } from './token-monitor.mjs';
5
+ import { normalizeProjectPath } from './state-file.mjs';
6
+
7
+ const {
8
+ parseArgs,
9
+ filterStates,
10
+ cellWidth,
11
+ truncateToCells,
12
+ padCellsEnd,
13
+ formatNumber,
14
+ renderBar,
15
+ formatLine,
16
+ } = _internal;
17
+
18
+ // state-file は projectPath を resolve + lowercase 正規化する。
19
+ // filterStates は cwd 引数を内部で正規化するので、テストでも同じ関数を使って揃える。
20
+ const CWD_FOO = normalizeProjectPath('/tmp/foo');
21
+ const CWD_BAR = normalizeProjectPath('/tmp/bar');
22
+
23
+ // ─── parseArgs ─────────────────────────────────────────────────────
24
+
25
+ test('parseArgs: 引数なしは defaults', () => {
26
+ assert.deepEqual(parseArgs([]), { all: false, session: null });
27
+ });
28
+
29
+ test('parseArgs: --all フラグ', () => {
30
+ assert.deepEqual(parseArgs(['--all']), { all: true, session: null });
31
+ });
32
+
33
+ test('parseArgs: --session <id>', () => {
34
+ assert.deepEqual(parseArgs(['--session', 'abc123']), { all: false, session: 'abc123' });
35
+ });
36
+
37
+ test('parseArgs: --all と --session の組み合わせ', () => {
38
+ assert.deepEqual(parseArgs(['--all', '--session', 'abc']), { all: true, session: 'abc' });
39
+ });
40
+
41
+ test('parseArgs: --session 値欠落は throw する', () => {
42
+ assert.throws(() => parseArgs(['--session']), /requires a session id/);
43
+ });
44
+
45
+ test('parseArgs: --session の次が別フラグなら throw する', () => {
46
+ assert.throws(() => parseArgs(['--session', '--all']), /requires a session id/);
47
+ });
48
+
49
+ test('parseArgs: 未知の引数は黙殺', () => {
50
+ // 将来 --help などを足す余地を残すため、現状は黙殺で OK
51
+ assert.deepEqual(parseArgs(['--unknown', 'value']), { all: false, session: null });
52
+ });
53
+
54
+ // ─── filterStates ─────────────────────────────────────────────────
55
+
56
+ function makeState({ sessionId, projectPath, stale = false }) {
57
+ return {
58
+ sessionId,
59
+ projectPath,
60
+ transcriptPath: null,
61
+ updatedAt: Date.now(),
62
+ stale,
63
+ };
64
+ }
65
+
66
+ test('filterStates: --all なしでは stale を隠す', () => {
67
+ const states = [
68
+ makeState({ sessionId: 'a', projectPath: CWD_FOO, stale: false }),
69
+ makeState({ sessionId: 'b', projectPath: CWD_FOO, stale: true }),
70
+ ];
71
+ const result = filterStates(states, { all: false, session: null }, CWD_FOO);
72
+ assert.equal(result.length, 1);
73
+ assert.equal(result[0].sessionId, 'a');
74
+ });
75
+
76
+ test('filterStates: --all は stale も含む', () => {
77
+ const states = [
78
+ makeState({ sessionId: 'a', projectPath: CWD_FOO, stale: false }),
79
+ makeState({ sessionId: 'b', projectPath: CWD_BAR, stale: true }),
80
+ ];
81
+ const result = filterStates(states, { all: true, session: null }, CWD_FOO);
82
+ assert.equal(result.length, 2);
83
+ });
84
+
85
+ test('filterStates: --session は base (stale フィルタ済み) 上でプレフィックス一致', () => {
86
+ const states = [
87
+ makeState({ sessionId: 'abc123', projectPath: CWD_FOO, stale: false }),
88
+ makeState({ sessionId: 'abc999', projectPath: CWD_FOO, stale: true }), // stale は除外される
89
+ makeState({ sessionId: 'def456', projectPath: CWD_FOO, stale: false }),
90
+ ];
91
+ const result = filterStates(states, { all: false, session: 'abc' }, CWD_FOO);
92
+ assert.equal(result.length, 1);
93
+ assert.equal(result[0].sessionId, 'abc123');
94
+ });
95
+
96
+ test('filterStates: --session + --all なら stale 含めたうえでプレフィックス一致', () => {
97
+ const states = [
98
+ makeState({ sessionId: 'abc123', projectPath: CWD_FOO, stale: false }),
99
+ makeState({ sessionId: 'abc999', projectPath: CWD_FOO, stale: true }),
100
+ ];
101
+ const result = filterStates(states, { all: true, session: 'abc' }, CWD_FOO);
102
+ assert.equal(result.length, 2);
103
+ });
104
+
105
+ test('filterStates: --session 完全一致もプレフィックス一致の一部として拾う', () => {
106
+ const states = [
107
+ makeState({ sessionId: 'exact-match-id', projectPath: CWD_FOO, stale: false }),
108
+ ];
109
+ const result = filterStates(states, { all: false, session: 'exact-match-id' }, CWD_FOO);
110
+ assert.equal(result.length, 1);
111
+ });
112
+
113
+ test('filterStates: cwd 不一致は除外(--session も --all もなし)', () => {
114
+ const states = [
115
+ makeState({ sessionId: 'a', projectPath: CWD_FOO, stale: false }),
116
+ makeState({ sessionId: 'b', projectPath: CWD_BAR, stale: false }),
117
+ ];
118
+ const result = filterStates(states, { all: false, session: null }, CWD_FOO);
119
+ assert.equal(result.length, 1);
120
+ assert.equal(result[0].sessionId, 'a');
121
+ });
122
+
123
+ // ─── cellWidth ─────────────────────────────────────────────────────
124
+
125
+ test('cellWidth: ASCII は 1 セル', () => {
126
+ assert.equal(cellWidth('abc'), 3);
127
+ assert.equal(cellWidth(''), 0);
128
+ assert.equal(cellWidth('Hello World'), 11);
129
+ });
130
+
131
+ test('cellWidth: CJK は 2 セル', () => {
132
+ assert.equal(cellWidth('あ'), 2);
133
+ assert.equal(cellWidth('漢字'), 4);
134
+ assert.equal(cellWidth('한글'), 4);
135
+ });
136
+
137
+ test('cellWidth: 絵文字は 2 セル', () => {
138
+ assert.equal(cellWidth('😀'), 2);
139
+ assert.equal(cellWidth('🚀🎉'), 4);
140
+ });
141
+
142
+ test('cellWidth: ANSI エスケープは 0 セル', () => {
143
+ assert.equal(cellWidth('\x1b[32mhello\x1b[0m'), 5);
144
+ assert.equal(cellWidth('\x1b[1;36m★\x1b[0m'), 1);
145
+ });
146
+
147
+ test('cellWidth: 混在 (ASCII + CJK + 絵文字 + ANSI)', () => {
148
+ const line = '\x1b[32m▶\x1b[0m Throughline プロジェクト';
149
+ // ▶ (U+25B6) は "Geometric Shapes" で現状 1 セル扱い、ASCII 12 + ひらがな 4 * 2
150
+ // "Throughline " = 12, "プロジェクト" = 12 (6 文字 * 2), ▶ = 1, 空白 = 1
151
+ assert.equal(cellWidth(line), 1 + 1 + 12 + 12);
152
+ });
153
+
154
+ test('cellWidth: 制御文字は 0', () => {
155
+ assert.equal(cellWidth('\x00\x01\x02'), 0);
156
+ });
157
+
158
+ test('cellWidth: ZWJ は 0', () => {
159
+ assert.equal(cellWidth('a\u200db'), 2); // a + ZWJ (0) + b
160
+ });
161
+
162
+ // ─── truncateToCells ──────────────────────────────────────────────
163
+
164
+ test('truncateToCells: ASCII で単純切り詰め', () => {
165
+ const result = truncateToCells('abcdefghij', 5);
166
+ // 切り詰め後に reset が付く
167
+ assert.ok(result.startsWith('abcde'));
168
+ });
169
+
170
+ test('truncateToCells: CJK 境界で 1 セル余る場合は空白で埋める', () => {
171
+ // maxCells=5 で "あいう" (6 セル) を切ると "あい" (4 セル) + 1 セル分空白
172
+ const result = truncateToCells('あいうえお', 5);
173
+ const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
174
+ // CJK 2 セル × 2 = 4 セル + 空白 1 = 5 セル
175
+ assert.ok(stripped.startsWith('あい '));
176
+ });
177
+
178
+ test('truncateToCells: 既に収まっていればそのまま', () => {
179
+ assert.equal(truncateToCells('abc', 10), 'abc');
180
+ });
181
+
182
+ test('truncateToCells: ANSI コードを破壊しない', () => {
183
+ const input = '\x1b[32mhello\x1b[0m world';
184
+ const result = truncateToCells(input, 7);
185
+ // ANSI がそのまま残り、可視部分は "hello w" で切れる
186
+ assert.ok(result.includes('\x1b[32m'));
187
+ });
188
+
189
+ test('truncateToCells: maxCells=0 は空文字列', () => {
190
+ assert.equal(truncateToCells('hello', 0), '');
191
+ });
192
+
193
+ // ─── padCellsEnd ──────────────────────────────────────────────────
194
+
195
+ test('padCellsEnd: ASCII を右端パディング', () => {
196
+ assert.equal(padCellsEnd('abc', 6), 'abc ');
197
+ });
198
+
199
+ test('padCellsEnd: CJK でも正しく幅を計算してパディング', () => {
200
+ // "あい" = 4 セル、target 6 → 空白 2 個付加
201
+ assert.equal(padCellsEnd('あい', 6), 'あい ');
202
+ });
203
+
204
+ test('padCellsEnd: ちょうど target なら変化なし', () => {
205
+ assert.equal(padCellsEnd('abc', 3), 'abc');
206
+ assert.equal(padCellsEnd('漢字', 4), '漢字');
207
+ });
208
+
209
+ test('padCellsEnd: target 超過なら切り詰め', () => {
210
+ const result = padCellsEnd('abcdefgh', 4);
211
+ const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
212
+ assert.equal(stripped.slice(0, 4), 'abcd');
213
+ });
214
+
215
+ // ─── formatNumber ─────────────────────────────────────────────────
216
+
217
+ test('formatNumber: 1000 未満はそのまま(小数なし)', () => {
218
+ assert.equal(formatNumber(0), '0');
219
+ assert.equal(formatNumber(42), '42');
220
+ assert.equal(formatNumber(999), '999');
221
+ });
222
+
223
+ test('formatNumber: 1_000 以上は k 表記', () => {
224
+ assert.equal(formatNumber(1_000), '1.0k');
225
+ assert.equal(formatNumber(1_234), '1.2k');
226
+ assert.equal(formatNumber(999_499), '999.5k');
227
+ });
228
+
229
+ test('formatNumber: 999_500 以上は M 表記にジャンプ("1000.0k" 回避)', () => {
230
+ assert.equal(formatNumber(999_500), '1.00M');
231
+ assert.equal(formatNumber(999_950), '1.00M');
232
+ assert.equal(formatNumber(999_999), '1.00M');
233
+ assert.equal(formatNumber(1_000_000), '1.00M');
234
+ assert.equal(formatNumber(1_234_567), '1.23M');
235
+ });
236
+
237
+ test('formatNumber: 無効値は "0"', () => {
238
+ assert.equal(formatNumber(NaN), '0');
239
+ assert.equal(formatNumber(-1), '0');
240
+ assert.equal(formatNumber(Infinity), '0');
241
+ });
242
+
243
+ // ─── renderBar ────────────────────────────────────────────────────
244
+
245
+ test('renderBar: ratio=0 は全部 ░', () => {
246
+ assert.equal(renderBar(0, 5), '░░░░░');
247
+ });
248
+
249
+ test('renderBar: ratio=1 は全部 █', () => {
250
+ assert.equal(renderBar(1, 5), '█████');
251
+ });
252
+
253
+ test('renderBar: ratio=0.5 は半々', () => {
254
+ // width=10, 0.5 * 10 = 5 filled
255
+ assert.equal(renderBar(0.5, 10), '█████░░░░░');
256
+ });
257
+
258
+ test('renderBar: NaN でもバーが消えない', () => {
259
+ const result = renderBar(NaN, 5);
260
+ assert.equal(result.length, 5);
261
+ assert.ok(result.includes('░'));
262
+ });
263
+
264
+ test('renderBar: Infinity / 負値は安全にクランプ', () => {
265
+ assert.equal(renderBar(Infinity, 5), '█████');
266
+ assert.equal(renderBar(-1, 5), '░░░░░');
267
+ assert.equal(renderBar(1.5, 5), '█████');
268
+ });
269
+
270
+ // ─── formatLine 警告表示(色覚配慮) ────────────────────────────
271
+
272
+ function stripColors(s) {
273
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
274
+ }
275
+
276
+ function makeLineArgs(ratio) {
277
+ const max = 200_000;
278
+ const tokens = Math.round(ratio * max);
279
+ return {
280
+ state: {
281
+ sessionId: 'abc12345-xxxx',
282
+ projectPath: '/tmp/foo',
283
+ transcriptPath: null,
284
+ updatedAt: Date.now(),
285
+ },
286
+ usage: {
287
+ tokens,
288
+ model: 'test-model',
289
+ contextWindowSize: max,
290
+ outputTokens: 0,
291
+ },
292
+ isActive: true,
293
+ };
294
+ }
295
+
296
+ test('formatLine: 70% 未満は警告テキストなし', () => {
297
+ const out = stripColors(formatLine(makeLineArgs(0.5)));
298
+ assert.ok(!out.includes('!!'));
299
+ assert.ok(!out.includes('! '));
300
+ assert.ok(!out.includes('/clear'));
301
+ });
302
+
303
+ test('formatLine: 70% 以上で "!" マーカーと弱めの文言', () => {
304
+ const out = stripColors(formatLine(makeLineArgs(0.75)));
305
+ assert.ok(out.includes('!'), 'should include ! marker');
306
+ assert.ok(out.includes('そろそろ /clear'), 'should show soft warning');
307
+ assert.ok(!out.includes('!!'), 'should not include critical marker yet');
308
+ });
309
+
310
+ test('formatLine: 90% 以上で "!!" マーカーと強い文言', () => {
311
+ const out = stripColors(formatLine(makeLineArgs(0.95)));
312
+ assert.ok(out.includes('!!'), 'should include !! critical marker');
313
+ assert.ok(out.includes('強く推奨'), 'should show strong warning');
314
+ });
315
+
316
+ test('formatLine: 色付きで警告が赤 / 黄になる(色覚配慮の裏付け)', () => {
317
+ const critical = formatLine(makeLineArgs(0.95));
318
+ assert.ok(critical.includes('\x1b[31m'), 'critical should use red');
319
+ const warning = formatLine(makeLineArgs(0.75));
320
+ assert.ok(warning.includes('\x1b[33m'), 'warning should use yellow');
321
+ });
322
+
323
+ test('formatLine: プロジェクト名に CJK が含まれてもセル幅で整形される', () => {
324
+ const args = makeLineArgs(0.5);
325
+ args.state.projectPath = '/tmp/プロジェクト名';
326
+ const out = formatLine(args);
327
+ // basename で "プロジェクト名" (7 文字, 14 セル) が project 欄に入る。
328
+ // padCellsEnd(..., 18) で 14 セル + 4 セル空白になる。セル幅を数える
329
+ // のは難しいがクラッシュしないことと想定文字列が含まれることを最低限確認
330
+ assert.ok(stripColors(out).includes('プロジェクト名'));
331
+ });
@@ -72,20 +72,26 @@ function hasContextWindowHint(raw) {
72
72
  return /\[1m\]|1M context/i.test(raw);
73
73
  }
74
74
 
75
- /** @type {Map<string, {size: number, sample: UsageSample|null}>} */
75
+ /** @type {Map<string, {size: number, mtimeMs: number, sample: UsageSample|null, stickyWindow: number}>} */
76
76
  const cache = new Map();
77
77
 
78
78
  /**
79
- * transcript JSONL から最新の assistant usage を抽出する
79
+ * transcript JSONL から最新の assistant usage を抽出する。
80
+ *
81
+ * キャッシュ無効化は size + mtimeMs の両方を比較する(同サイズで内容差し替えも検出)。
82
+ * sticky 1M: 一度でも 1M 文脈窓と判定した path は以後 1M のまま(200k→1M 境界での
83
+ * バー急変を防ぐ)。プロセス再起動でリセット、monitor 再起動時でも初回サンプルで
84
+ * 即復帰するので実害なし。
85
+ *
80
86
  * @param {string} transcriptPath
81
87
  * @returns {UsageSample | null}
82
88
  */
83
89
  export function readLatestUsage(transcriptPath) {
84
90
  if (!transcriptPath || !existsSync(transcriptPath)) return null;
85
91
 
86
- const { size } = statSync(transcriptPath);
92
+ const stat = statSync(transcriptPath);
87
93
  const cached = cache.get(transcriptPath);
88
- if (cached && cached.size === size) {
94
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
89
95
  return cached.sample;
90
96
  }
91
97
 
@@ -110,15 +116,30 @@ export function readLatestUsage(transcriptPath) {
110
116
  (usage.cache_creation_input_tokens ?? 0) +
111
117
  (usage.cache_read_input_tokens ?? 0);
112
118
  const model = entry.message?.model ?? '';
119
+ const detected = inferContextWindowSize(model, tokens, rawHint);
113
120
  latest = {
114
121
  tokens,
115
122
  model,
116
- contextWindowSize: inferContextWindowSize(model, tokens, rawHint),
123
+ contextWindowSize: detected,
117
124
  outputTokens: usage.output_tokens ?? 0,
118
125
  };
119
126
  }
120
127
 
121
- cache.set(transcriptPath, { size, sample: latest });
128
+ // sticky 1M: 既に 1M を観測していたらその値を維持
129
+ const priorSticky = cached?.stickyWindow ?? 0;
130
+ const stickyWindow = latest
131
+ ? Math.max(latest.contextWindowSize, priorSticky)
132
+ : priorSticky;
133
+ if (latest && stickyWindow > latest.contextWindowSize) {
134
+ latest.contextWindowSize = stickyWindow;
135
+ }
136
+
137
+ cache.set(transcriptPath, {
138
+ size: stat.size,
139
+ mtimeMs: stat.mtimeMs,
140
+ sample: latest,
141
+ stickyWindow,
142
+ });
122
143
  return latest;
123
144
  }
124
145
 
@@ -0,0 +1,159 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, writeFileSync, rmSync, utimesSync, statSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ import { readLatestUsage, clearUsageCache, inferContextWindowSize } from './transcript-usage.mjs';
8
+
9
+ function writeTranscript(path, entries) {
10
+ writeFileSync(path, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
11
+ }
12
+
13
+ function assistantEntry({ model, inputTokens, cacheCreate = 0, cacheRead = 0, outputTokens = 0 }) {
14
+ return {
15
+ type: 'assistant',
16
+ message: {
17
+ model,
18
+ usage: {
19
+ input_tokens: inputTokens,
20
+ cache_creation_input_tokens: cacheCreate,
21
+ cache_read_input_tokens: cacheRead,
22
+ output_tokens: outputTokens,
23
+ },
24
+ },
25
+ };
26
+ }
27
+
28
+ function withFixture(fn) {
29
+ const dir = mkdtempSync(join(tmpdir(), 'tl-usage-'));
30
+ const path = join(dir, 'transcript.jsonl');
31
+ clearUsageCache();
32
+ try {
33
+ fn({ dir, path });
34
+ } finally {
35
+ clearUsageCache();
36
+ rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ }
39
+
40
+ // ─── inferContextWindowSize ────────────────────────────────────────
41
+
42
+ test('inferContextWindowSize: [1m] サフィックスは 1M', () => {
43
+ assert.equal(inferContextWindowSize('claude-opus-4-6[1m]', 0, false), 1_000_000);
44
+ assert.equal(inferContextWindowSize('claude-sonnet-4-7[1M]', 0, false), 1_000_000);
45
+ });
46
+
47
+ test('inferContextWindowSize: rawHint=true なら 1M', () => {
48
+ assert.equal(inferContextWindowSize('claude-opus-4-6', 0, true), 1_000_000);
49
+ });
50
+
51
+ test('inferContextWindowSize: 200k 超観測でも 1M', () => {
52
+ assert.equal(inferContextWindowSize('claude-opus-4-6', 250_000, false), 1_000_000);
53
+ });
54
+
55
+ test('inferContextWindowSize: デフォルトは 200k', () => {
56
+ assert.equal(inferContextWindowSize('claude-opus-4-6', 100_000, false), 200_000);
57
+ assert.equal(inferContextWindowSize('', 0, false), 200_000);
58
+ });
59
+
60
+ // ─── readLatestUsage ──────────────────────────────────────────────
61
+
62
+ test('readLatestUsage: 存在しないファイルは null', () => {
63
+ assert.equal(readLatestUsage('/nonexistent/path'), null);
64
+ assert.equal(readLatestUsage(''), null);
65
+ assert.equal(readLatestUsage(null), null);
66
+ });
67
+
68
+ test('readLatestUsage: 最新の assistant エントリを返す', () => {
69
+ withFixture(({ path }) => {
70
+ writeTranscript(path, [
71
+ assistantEntry({ model: 'claude-opus-4-6', inputTokens: 1000 }),
72
+ { type: 'user', message: { content: 'hi' } },
73
+ assistantEntry({ model: 'claude-opus-4-6', inputTokens: 5000, cacheRead: 1000, outputTokens: 100 }),
74
+ ]);
75
+ const result = readLatestUsage(path);
76
+ assert.ok(result);
77
+ assert.equal(result.tokens, 6000);
78
+ assert.equal(result.model, 'claude-opus-4-6');
79
+ assert.equal(result.outputTokens, 100);
80
+ assert.equal(result.contextWindowSize, 200_000);
81
+ });
82
+ });
83
+
84
+ test('readLatestUsage: usage なしエントリは skip', () => {
85
+ withFixture(({ path }) => {
86
+ writeTranscript(path, [
87
+ { type: 'assistant', message: { model: 'x', content: [] } },
88
+ assistantEntry({ model: 'claude-opus-4-6', inputTokens: 500 }),
89
+ ]);
90
+ const result = readLatestUsage(path);
91
+ assert.equal(result.tokens, 500);
92
+ });
93
+ });
94
+
95
+ test('readLatestUsage: キャッシュが mtime 変化で無効化される', () => {
96
+ withFixture(({ path }) => {
97
+ writeTranscript(path, [assistantEntry({ model: 'x', inputTokens: 100 })]);
98
+ const first = readLatestUsage(path);
99
+ assert.equal(first.tokens, 100);
100
+
101
+ // 同じサイズで中身を差し替え、mtime も進める
102
+ writeTranscript(path, [assistantEntry({ model: 'x', inputTokens: 999 })]);
103
+ // 強制的に mtime を 2 秒後にする(OS によっては書き込み直後でも mtime が同じことがある)
104
+ const future = new Date(Date.now() + 2000);
105
+ utimesSync(path, future, future);
106
+
107
+ const second = readLatestUsage(path);
108
+ assert.equal(second.tokens, 999, 'cache should be invalidated by mtime change');
109
+ });
110
+ });
111
+
112
+ // ─── sticky 1M ──────────────────────────────────────────────────
113
+
114
+ test('readLatestUsage: 一度 1M を観測したら以後は下がらない', () => {
115
+ withFixture(({ path }) => {
116
+ // 初回: 250k 観測 → 1M 判定
117
+ writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 250_000 })]);
118
+ const first = readLatestUsage(path);
119
+ assert.equal(first.contextWindowSize, 1_000_000);
120
+
121
+ // 次: 100k に下がっても window は 1M のまま維持(sticky)
122
+ writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 100_000 })]);
123
+ const future = new Date(Date.now() + 2000);
124
+ utimesSync(path, future, future);
125
+
126
+ const second = readLatestUsage(path);
127
+ assert.equal(second.tokens, 100_000);
128
+ assert.equal(second.contextWindowSize, 1_000_000, 'sticky 1M must remain after observation');
129
+ });
130
+ });
131
+
132
+ test('readLatestUsage: clearUsageCache で sticky もリセット', () => {
133
+ withFixture(({ path }) => {
134
+ writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 250_000 })]);
135
+ readLatestUsage(path);
136
+ clearUsageCache();
137
+
138
+ writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 100_000 })]);
139
+ const future = new Date(Date.now() + 2000);
140
+ utimesSync(path, future, future);
141
+
142
+ const result = readLatestUsage(path);
143
+ assert.equal(result.contextWindowSize, 200_000, 'sticky should reset after clearUsageCache');
144
+ });
145
+ });
146
+
147
+ test('readLatestUsage: partial-write JSONL 行は skip', () => {
148
+ withFixture(({ path }) => {
149
+ // 最後の行が壊れている
150
+ writeFileSync(
151
+ path,
152
+ JSON.stringify(assistantEntry({ model: 'x', inputTokens: 42 })) +
153
+ '\n' +
154
+ '{"type":"assistant","message":{"model":"x","us',
155
+ );
156
+ const result = readLatestUsage(path);
157
+ assert.equal(result.tokens, 42);
158
+ });
159
+ });