throughline 0.4.3 → 0.4.5

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/CHANGELOG.md CHANGED
@@ -10,6 +10,27 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.5] — 2026-05-09
14
+
15
+ ### Fixed
16
+
17
+ - VS Code detection now treats `VSCODE_HANDLES_SIGPIPE` as a VS Code-family
18
+ environment signal. This lets `throughline install` provision the monitor task
19
+ in Codex / VS Code sessions where `TERM_PROGRAM`, `VSCODE_PID`, and
20
+ `VSCODE_IPC_HOOK_CLI` are absent.
21
+
22
+ ## [0.4.4] — 2026-05-09
23
+
24
+ ### Changed
25
+
26
+ - Token monitor now treats Claude transcript and Codex rollout files as live
27
+ inputs. State-file `usage` snapshots remain a fallback, but the display and
28
+ stale hiding no longer wait for Stop hook completion when the live files are
29
+ still changing.
30
+ - `throughline install` now provisions or repairs the current project's VS Code
31
+ `Throughline Monitor` task when running under VS Code / Cursor / VSCodium, so
32
+ monitor auto-start setup no longer depends solely on the first hook event.
33
+
13
34
  ## [0.4.3] — 2026-05-09
14
35
 
15
36
  ### Changed
package/README.ja.md CHANGED
@@ -213,6 +213,10 @@ throughline monitor --session <id-prefix>
213
213
  ▶ Throughline 2ed5039c ████░░░░░░░░░░░░░░░░ 205.1k / 21% 残 794.9k claude-opus-4-6
214
214
  ```
215
215
 
216
+ 監視中は Claude transcript / Codex rollout をライブに読み、Stop hook の state
217
+ snapshot はライブ usage が取れない場合の控えとして使います。これにより表示更新は
218
+ Stop 完了待ちではなくなります。
219
+
216
220
  詳細仕様 (resize 追従、1M context 検出、ステイル隠し、Stop hook の非同期化など) は
217
221
  [英語版 README](README.md#multi-session-token-monitor) を参照してください。
218
222
 
@@ -222,7 +226,7 @@ throughline monitor --session <id-prefix>
222
226
 
223
227
  | コマンド | 役割 |
224
228
  | --- | --- |
225
- | `throughline install` | `~/.claude/settings.json` (ユーザー全体) hook を登録 |
229
+ | `throughline install` | hook / Codex skill を登録し、VS Code 配下なら現プロジェクトの monitor task も配置 |
226
230
  | `throughline install --project` | 現リポジトリの `.claude/settings.json` だけに hook を登録 |
227
231
  | `throughline uninstall` | hook を削除 |
228
232
  | `throughline monitor` | マルチセッション監視を起動 |
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  ```bash
20
20
  npm install -g throughline
21
- throughline install # registers Claude hooks, Codex Stop hook, and Codex skill
21
+ throughline install # registers hooks/skills and provisions the VS Code monitor task
22
22
  ```
23
23
 
24
24
  That's it. Open any Claude Code session and your turns flow into
@@ -517,9 +517,10 @@ Example output:
517
517
  (`input_tokens + cache_creation_input_tokens + cache_read_input_tokens`).
518
518
  No `length / 4` approximation.
519
519
  - **Codex token counts use the rollout `token_count` event when present.** The
520
- Codex Stop hook writes `codex:<thread_id>` monitor state and snapshots the
521
- latest verified rollout `token_count` sample. If a Codex rollout has no
522
- token-count event, Throughline can store an explicit estimate with
520
+ Codex Stop hook writes `codex:<thread_id>` monitor state with the rollout
521
+ path. While the monitor is running it reads the live rollout every tick and
522
+ prefers the latest verified `token_count` sample. If a Codex rollout has no
523
+ token-count event, Throughline can show an explicit estimate with
523
524
  `estimated: true` and the monitor marks it with `est`; it is not presented as
524
525
  exact usage.
525
526
  - **Codex auto-refresh mutates at the verified 90% threshold.** The Codex Stop
@@ -554,17 +555,17 @@ Example output:
554
555
  frame so the previous, wrongly-sized frame can't stack beneath it.
555
556
  - **Per-row "last updated" stamp.** Each session row carries an 8-cell
556
557
  `just now` / `24m ago` stamp right after the session id, placed before the
557
- bar so narrow terminals don't truncate it. It resets to `just now` on every
558
- Stop hook, so a growing stamp means the session is truly idle — not the
559
- monitor stuck. When you need more detail,
558
+ bar so narrow terminals don't truncate it. It follows the newest state,
559
+ transcript, or Codex rollout mtime, so active sessions stay visible and the
560
+ stamp can move before the next Stop hook completes. When you need more detail,
560
561
  `throughline doctor --session <id-prefix>` compares the state file against
561
562
  the actual transcript JSONL and flags drift, idle time, and
562
563
  `/clear`-induced transcript path staleness.
563
- - **State-backed usage snapshot.** When the Stop hook finishes a turn it
564
- persists the latest `tokens / model / contextWindowSize` back into the state
565
- file. The monitor prefers this snapshot over re-reading the JSONL, which
566
- removes a source of flicker when the transcript path in state drifts from
567
- the one Claude Code is currently appending to.
564
+ - **Live usage first, state snapshot as fallback.** When the Stop hook finishes
565
+ a turn it persists the latest `tokens / model / contextWindowSize` back into
566
+ the state file. The monitor now prefers live Claude transcript / Codex rollout
567
+ reads and uses the snapshot only when the live file cannot provide usage, so
568
+ the display no longer waits for Stop to update.
568
569
  - **Host-aware state.** Missing `host` means an older Claude state file.
569
570
  Codex states use `host: "codex"`, keep `transcriptPath: null`, and store the
570
571
  Codex rollout path separately as `rolloutPath` so the Claude transcript parser
@@ -581,15 +582,17 @@ Example output:
581
582
 
582
583
  ### VS Code auto-start (automatic)
583
584
 
584
- After `throughline install`, any VS Code / Cursor / VSCodium project you work in
585
- gets `.vscode/tasks.json` provisioned automatically on the first session event.
586
- The file configures `runOn: folderOpen` so the monitor appears in a dedicated
587
- terminal panel the next time you open that folder.
585
+ After `throughline install`, the current VS Code / Cursor / VSCodium project
586
+ gets `.vscode/tasks.json` provisioned immediately when VS Code environment
587
+ variables are present. Any other VS Code project you work in also gets the file
588
+ on the first session event. The file configures `runOn: folderOpen` so the
589
+ monitor appears in a dedicated terminal panel the next time you open that
590
+ folder.
588
591
 
589
- **How it works.** `ensureMonitorTaskFile` is called from **all three hooks
590
- (SessionStart, UserPromptSubmit, Stop)** as of v0.3.18. Whichever one fires
591
- first in your environment creates the file; the rest are idempotent no-ops.
592
- Once per project it inspects `.vscode/tasks.json`:
592
+ **How it works.** `ensureMonitorTaskFile` is called from `throughline install`
593
+ and from **all three hooks (SessionStart, UserPromptSubmit, Stop)**. Whichever
594
+ one fires first in your environment creates the file; the rest are idempotent
595
+ no-ops. Once per project it inspects `.vscode/tasks.json`:
593
596
 
594
597
  - **No file yet** → creates one with a single `Throughline Monitor` task, and
595
598
  emits a one-time `<system-reminder>` to stdout so Claude tells you a
@@ -647,7 +650,7 @@ entry to the `tasks` array yourself:
647
650
 
648
651
  | Command | What it does |
649
652
  | ---------------------------------------------- | ------------------------------------------------------------ |
650
- | `throughline install` | Register Claude user hooks/slash commands, the global Codex Stop hook, and the global `$throughline` Codex skill |
653
+ | `throughline install` | Register Claude user hooks/slash commands, the global Codex Stop hook, the global `$throughline` Codex skill, and the current VS Code monitor task when applicable |
651
654
  | `throughline install --project` | Register Claude hooks/slash commands in this repo only |
652
655
  | `throughline uninstall` | Remove Throughline-managed Claude hooks/slash commands, only the Throughline-managed Codex hook, and the `$throughline` Codex skill |
653
656
  | `throughline monitor [--all] [--session <id>]` | Run the multi-session token monitor |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -15,6 +15,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFi
15
15
  import { join, dirname, resolve, delimiter } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { homedir } from 'node:os';
18
+ import { ensureMonitorTaskFile, shouldRecommendGitignore } from '../vscode-task.mjs';
18
19
 
19
20
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
20
21
  const SLASH_COMMANDS_SRC = join(PACKAGE_ROOT, '.claude', 'commands');
@@ -400,6 +401,10 @@ export async function run(args = []) {
400
401
  const { installed: installedCommands, skipped } = installSlashCommands(commandsDir);
401
402
  const codex = args.includes('--project') ? null : installCodexHooks();
402
403
  const codexSkills = args.includes('--project') ? { installed: [], skipped: null } : installCodexSkills(codexSkillsDir);
404
+ const monitorTask = ensureMonitorTaskFile({
405
+ cwd: process.cwd(),
406
+ env: { ...process.env, THROUGHLINE_SUPPRESS_VSCODE_NOTICES: '1' },
407
+ });
403
408
 
404
409
  const scope = args.includes('--project') ? 'プロジェクトローカル' : 'グローバル(全プロジェクト)';
405
410
  console.log(`Throughline hooks をインストールしました [${scope}]`);
@@ -436,6 +441,15 @@ export async function run(args = []) {
436
441
  console.log('注意: パッケージ内に Codex skills のソースが見つからないためスキップしました。');
437
442
  console.log('');
438
443
  }
444
+ if (monitorTask.action === 'created' || monitorTask.action === 'merged' || monitorTask.action === 'repaired') {
445
+ console.log(`VSCode monitor task を${monitorTask.action === 'repaired' ? '修復' : '配置'}しました:`);
446
+ console.log(` ${monitorTask.path}`);
447
+ console.log(' 既に VSCode でこのフォルダを開いている場合は Developer: Reload Window を 1 回実行してください。');
448
+ if (shouldRecommendGitignore(process.cwd())) {
449
+ console.log(' 共有リポジトリでは .vscode/tasks.json を .gitignore に追加することを推奨します。');
450
+ }
451
+ console.log('');
452
+ }
439
453
  console.log(' アンインストール: throughline uninstall');
440
454
 
441
455
  if (!resolveThroughlineOnPath()) {
@@ -30,13 +30,16 @@ function silence() {
30
30
  const origLog = console.log;
31
31
  const origErr = console.error;
32
32
  const origStderrWrite = process.stderr.write.bind(process.stderr);
33
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
33
34
  console.log = () => {};
34
35
  console.error = () => {};
35
36
  process.stderr.write = () => true;
37
+ process.stdout.write = () => true;
36
38
  return () => {
37
39
  console.log = origLog;
38
40
  console.error = origErr;
39
41
  process.stderr.write = origStderrWrite;
42
+ process.stdout.write = origStdoutWrite;
40
43
  };
41
44
  }
42
45
 
@@ -144,6 +147,37 @@ test('global install copies Throughline Codex skill to ~/.codex/skills/', async
144
147
  }
145
148
  });
146
149
 
150
+ test('global install provisions VSCode monitor task for the current project when running under VSCode', async () => {
151
+ const home = makeTempHome();
152
+ if (home.resolved !== home.dir) {
153
+ home.restore();
154
+ return;
155
+ }
156
+ const projectDir = mkdtempSync(join(tmpdir(), 'tl-install-monitor-'));
157
+ const origCwd = process.cwd();
158
+ const origVscodePid = process.env.VSCODE_PID;
159
+ process.chdir(projectDir);
160
+ process.env.VSCODE_PID = '12345';
161
+ const unsilence = silence();
162
+ try {
163
+ await run([]);
164
+ const tasksPath = join(projectDir, '.vscode', 'tasks.json');
165
+ assert.ok(existsSync(tasksPath), 'install should create current-project VSCode tasks.json');
166
+ const tasks = JSON.parse(readFileSync(tasksPath, 'utf8'));
167
+ const task = tasks.tasks.find((t) => t.label === 'Throughline Monitor');
168
+ assert.ok(task, 'Throughline Monitor task should be present');
169
+ assert.deepEqual(task.args.slice(1), ['monitor']);
170
+ assert.deepEqual(task.runOptions, { runOn: 'folderOpen' });
171
+ } finally {
172
+ unsilence();
173
+ if (origVscodePid === undefined) delete process.env.VSCODE_PID;
174
+ else process.env.VSCODE_PID = origVscodePid;
175
+ process.chdir(origCwd);
176
+ home.restore();
177
+ rmSync(projectDir, { recursive: true, force: true });
178
+ }
179
+ });
180
+
147
181
  test('global install preserves existing Codex hooks and is idempotent', async () => {
148
182
  const home = makeTempHome();
149
183
  if (home.resolved !== home.dir) {
@@ -47,10 +47,9 @@ export function normalizeProjectPath(p) {
47
47
  * host?: 'claude'|'codex',
48
48
  * }} data
49
49
  *
50
- * usage: monitor が表示する tokens/model/contextWindowSize をここに固定保存する。
51
- * Stop hook readLatestUsage の結果を載せることで、monitor 側が毎フレーム JSONL
52
- * 再スキャンする必要がなくなる。旧バージョン互換のため optional (無ければ monitor が
53
- * transcriptPath を読んでフォールバック)。
50
+ * usage: Stop hook 完了時点の tokens/model/contextWindowSize fallback snapshot。
51
+ * monitor はライブ transcript / rollout を優先して読み、ライブ usage が取れない場合だけ
52
+ * この snapshot を使う。旧バージョン互換のため optional。
54
53
  */
55
54
  export function writeSessionState({
56
55
  sessionId,
@@ -13,16 +13,18 @@
13
13
  * - 状態ファイルはセッション単位 (~/.throughline/state/<session_id>.json)
14
14
  * - setInterval (1s) + mtime 差分検知で更新を捕捉
15
15
  * - updatedAt 降順ソート、先頭行を ▶ でハイライト
16
- * - stale は PID 生存チェックで判定
16
+ * - stale は state 更新時刻 + live transcript / rollout mtime で判定
17
17
  * - Claude は transcript JSONL の最新 assistant usage を直読
18
- * - Codex は Stop hook state.usage に固定した rollout usage / estimate を表示
18
+ * - Codex は rollout JSONL token_count / active-text estimate を直読
19
+ * - state.usage は live ファイルが読めない場合の最後の既知値としてだけ使う
19
20
  */
20
21
 
21
22
  import { basename, dirname, join } from 'node:path';
22
23
  import { stripVTControlCharacters } from 'node:util';
23
24
  import { statSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
24
25
  import { homedir } from 'node:os';
25
- import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjectPath } from './state-file.mjs';
26
+ import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjectPath, STALE_HIDE_MS } from './state-file.mjs';
27
+ import { buildCodexMonitorUsage } from './codex-usage.mjs';
26
28
  import { readLatestUsage } from './transcript-usage.mjs';
27
29
  import { startSizeQuery } from './terminal-size.mjs';
28
30
 
@@ -332,6 +334,44 @@ function formatLine({ state, usage, isActive, now = Date.now() }) {
332
334
  return `${marker} ${projectCol} ${hostCol} ${idCol} ${agoCol} ${barCol} ${tokCol} ${modelCol}${warn}`;
333
335
  }
334
336
 
337
+ function statFile(path) {
338
+ if (!path || !existsSync(path)) return null;
339
+ try {
340
+ return statSync(path);
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ function liveActivityMs(state) {
347
+ const transcript = statFile(state.transcriptPath);
348
+ const rollout = statFile(state.rolloutPath);
349
+ return Math.max(
350
+ Number(state.updatedAt) || 0,
351
+ transcript?.mtimeMs ?? 0,
352
+ rollout?.mtimeMs ?? 0,
353
+ );
354
+ }
355
+
356
+ function withLiveActivity(state, now = Date.now()) {
357
+ const updatedAt = liveActivityMs(state);
358
+ return {
359
+ ...state,
360
+ updatedAt,
361
+ stale: now - updatedAt > STALE_HIDE_MS,
362
+ };
363
+ }
364
+
365
+ function resolveMonitorUsage(state) {
366
+ if (state.host === 'codex' && state.rolloutPath) {
367
+ return buildCodexMonitorUsage(state.rolloutPath) ?? state.usage ?? null;
368
+ }
369
+ if (state.transcriptPath) {
370
+ return readLatestUsage(state.transcriptPath) ?? state.usage ?? null;
371
+ }
372
+ return state.usage ?? null;
373
+ }
374
+
335
375
  // --- フィルタ ---
336
376
  /**
337
377
  * セッション一覧に表示フィルタを適用する。
@@ -356,12 +396,12 @@ let lastRenderedLines = 0;
356
396
  let lastRenderKey = '';
357
397
 
358
398
  /**
359
- * 再描画要否の判定キー。state ファイル群の mtime と transcript JSONL の size を
399
+ * 再描画要否の判定キー。state ファイル群の mtime と transcript / rollout JSONL の
400
+ * size + mtime を
360
401
  * 1 本の文字列にまとめてハッシュキーとする。キーが前回と同じなら描画スキップ。
361
402
  *
362
- * 注: transcript は JSONL append-only なので size 変化 = 新しい usage エントリ到来と
363
- * 同義。mtime だけでは transcript 更新を検出できない(state-file mtime
364
- * Stop hook のタイミングで更新され、transcript は Claude の stream 中に太る)。
403
+ * 注: state-file mtime Stop hook のタイミングで更新されるが、
404
+ * transcript / rollout は実行中に太る。その live file 変化も render key に含める。
365
405
  */
366
406
  function computeRenderKey() {
367
407
  const parts = [];
@@ -369,16 +409,18 @@ function computeRenderKey() {
369
409
  const mtimes = snapshotStateMtimes();
370
410
  const names = Array.from(mtimes.keys()).sort();
371
411
  for (const name of names) parts.push(`s:${name}:${mtimes.get(name)}`);
372
- // transcript sizes(state ファイルを読まずに直接 stat、IO 最小化)
412
+ // live transcript / rollout sizes(state ファイルを読まずに直接 stat、IO 最小化)
373
413
  try {
374
414
  const states = readAllSessionStates();
375
415
  for (const st of states) {
376
- if (!st.transcriptPath || !existsSync(st.transcriptPath)) continue;
377
- try {
378
- const size = statSync(st.transcriptPath).size;
379
- parts.push(`t:${st.sessionId}:${size}`);
380
- } catch {
381
- // stat 失敗は無視(次フレームで回復)
416
+ for (const [kind, path] of [['t', st.transcriptPath], ['r', st.rolloutPath]]) {
417
+ if (!path || !existsSync(path)) continue;
418
+ try {
419
+ const stat = statSync(path);
420
+ parts.push(`${kind}:${st.sessionId}:${stat.size}:${stat.mtimeMs}`);
421
+ } catch {
422
+ // stat 失敗は無視(次フレームで回復)
423
+ }
382
424
  }
383
425
  }
384
426
  } catch {
@@ -405,7 +447,8 @@ function resetRenderKeyCache() {
405
447
  }
406
448
 
407
449
  function renderFrame(args) {
408
- const states = readAllSessionStates();
450
+ const now = Date.now();
451
+ const states = readAllSessionStates().map((state) => withLiveActivity(state, now));
409
452
  const filtered = filterStates(states, args, process.cwd()).sort(
410
453
  (a, b) => b.updatedAt - a.updatedAt,
411
454
  );
@@ -428,15 +471,9 @@ function renderFrame(args) {
428
471
  `[Throughline] ${filtered.length} セッション${args.all ? ' (--all)' : ''}`,
429
472
  );
430
473
  lines.push(header);
431
- const now = Date.now();
432
474
  for (let i = 0; i < filtered.length; i++) {
433
475
  const state = filtered[i];
434
- // Stop hook state.usage に固定値を入れていればそれを使う(JSONL 再スキャン不要)。
435
- // 旧バージョンが書いた Claude state や usage スナップショットが取れなかったターンでは
436
- // transcriptPath を直読。state 側の情報が 1 本化されると
437
- // 「state が古い JSONL を指している」時の表示ブレが減る。
438
- const usage = state.usage
439
- ?? (state.transcriptPath ? readLatestUsage(state.transcriptPath) : null);
476
+ const usage = resolveMonitorUsage(state);
440
477
  lines.push(formatLine({ state, usage, isActive: i === 0, now }));
441
478
  }
442
479
  }
@@ -698,6 +735,9 @@ export const _internal = {
698
735
  shouldForceFullRedraw,
699
736
  resolveColumns,
700
737
  setMeasuredColumns,
738
+ liveActivityMs,
739
+ withLiveActivity,
740
+ resolveMonitorUsage,
701
741
  };
702
742
 
703
743
  // --- エントリポイント自動起動 ---
@@ -1,5 +1,8 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
3
6
 
4
7
  import { _internal } from './token-monitor.mjs';
5
8
  import { normalizeProjectPath } from './state-file.mjs';
@@ -17,6 +20,8 @@ const {
17
20
  shouldForceFullRedraw,
18
21
  resolveColumns,
19
22
  setMeasuredColumns,
23
+ withLiveActivity,
24
+ resolveMonitorUsage,
20
25
  } = _internal;
21
26
 
22
27
  // state-file は projectPath を resolve + lowercase 正規化する。
@@ -128,6 +133,117 @@ test('filterStates: cwd 不一致は除外(--session も --all もなし)',
128
133
  assert.equal(result[0].sessionId, 'a');
129
134
  });
130
135
 
136
+ test('withLiveActivity: transcript mtime keeps a long-running session visible before Stop', () => {
137
+ const dir = mkdtempSync(join(tmpdir(), 'tl-monitor-live-activity-'));
138
+ try {
139
+ const transcript = join(dir, 'session.jsonl');
140
+ writeFileSync(transcript, '{"type":"user","message":{"content":"working"}}\n');
141
+ const now = Date.now();
142
+ const old = now - (20 * 60 * 1000);
143
+ const state = withLiveActivity({
144
+ sessionId: 'live-session',
145
+ host: 'claude',
146
+ projectPath: CWD_FOO,
147
+ transcriptPath: transcript,
148
+ rolloutPath: null,
149
+ updatedAt: old,
150
+ stale: true,
151
+ }, now);
152
+
153
+ assert.equal(state.stale, false);
154
+ assert.ok(state.updatedAt > old);
155
+ } finally {
156
+ rmSync(dir, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ test('resolveMonitorUsage: live Claude transcript overrides stale Stop snapshot', () => {
161
+ const dir = mkdtempSync(join(tmpdir(), 'tl-monitor-live-usage-'));
162
+ try {
163
+ const transcript = join(dir, 'session.jsonl');
164
+ writeFileSync(transcript, [
165
+ JSON.stringify({
166
+ type: 'assistant',
167
+ message: {
168
+ model: 'claude-opus-4-6',
169
+ usage: {
170
+ input_tokens: 1234,
171
+ cache_creation_input_tokens: 200,
172
+ cache_read_input_tokens: 300,
173
+ output_tokens: 10,
174
+ },
175
+ },
176
+ }),
177
+ '',
178
+ ].join('\n'));
179
+
180
+ const usage = resolveMonitorUsage({
181
+ sessionId: 'claude-session',
182
+ host: 'claude',
183
+ projectPath: CWD_FOO,
184
+ transcriptPath: transcript,
185
+ rolloutPath: null,
186
+ updatedAt: Date.now(),
187
+ usage: {
188
+ tokens: 1,
189
+ model: 'old-snapshot',
190
+ contextWindowSize: 200_000,
191
+ outputTokens: 0,
192
+ },
193
+ });
194
+
195
+ assert.equal(usage.tokens, 1734);
196
+ assert.equal(usage.model, 'claude-opus-4-6');
197
+ } finally {
198
+ rmSync(dir, { recursive: true, force: true });
199
+ }
200
+ });
201
+
202
+ test('resolveMonitorUsage: live Codex rollout overrides stale Stop snapshot', () => {
203
+ const dir = mkdtempSync(join(tmpdir(), 'tl-monitor-live-codex-'));
204
+ try {
205
+ const rollout = join(dir, 'rollout.jsonl');
206
+ writeFileSync(rollout, [
207
+ JSON.stringify({
208
+ type: 'turn_context',
209
+ payload: { model: 'gpt-5.5' },
210
+ }),
211
+ JSON.stringify({
212
+ type: 'event_msg',
213
+ payload: {
214
+ type: 'token_count',
215
+ info: {
216
+ last_token_usage: { input_tokens: 4567, output_tokens: 89 },
217
+ model_context_window: 258400,
218
+ },
219
+ },
220
+ }),
221
+ '',
222
+ ].join('\n'));
223
+
224
+ const usage = resolveMonitorUsage({
225
+ sessionId: 'codex:thread',
226
+ host: 'codex',
227
+ projectPath: CWD_FOO,
228
+ transcriptPath: null,
229
+ rolloutPath: rollout,
230
+ updatedAt: Date.now(),
231
+ usage: {
232
+ tokens: 1,
233
+ model: 'old-snapshot',
234
+ contextWindowSize: 200_000,
235
+ outputTokens: 0,
236
+ },
237
+ });
238
+
239
+ assert.equal(usage.tokens, 4567);
240
+ assert.equal(usage.model, 'gpt-5.5');
241
+ assert.equal(usage.estimated, false);
242
+ } finally {
243
+ rmSync(dir, { recursive: true, force: true });
244
+ }
245
+ });
246
+
131
247
  // ─── cellWidth ─────────────────────────────────────────────────────
132
248
 
133
249
  test('cellWidth: ASCII は 1 セル', () => {
@@ -279,9 +279,8 @@ export async function run() {
279
279
  }
280
280
  }
281
281
 
282
- // monitor JSONL を毎フレーム再スキャンせずに済むよう、現在確定している usage を
283
- // state ファイルに固定する。Stop 完了時点で assistant エントリは transcript に
284
- // 書き出し済みなので readLatestUsage が最新値を返す。
282
+ // monitor fallback 用に、Stop 完了時点で確定している usage を state ファイルにも
283
+ // 保存する。通常表示はライブ transcript を優先し、読めない時だけ snapshot を使う。
285
284
  // 取得失敗は致命ではないので try/catch で握る(stderr には出す)。
286
285
  try {
287
286
  const usage = transcript_path ? readLatestUsage(transcript_path) : null;
@@ -29,7 +29,8 @@ export function detectVsCode(env) {
29
29
  return (
30
30
  env.TERM_PROGRAM === 'vscode' ||
31
31
  Boolean(env.VSCODE_PID) ||
32
- Boolean(env.VSCODE_IPC_HOOK_CLI)
32
+ Boolean(env.VSCODE_IPC_HOOK_CLI) ||
33
+ Boolean(env.VSCODE_HANDLES_SIGPIPE)
33
34
  );
34
35
  }
35
36
 
@@ -49,6 +49,10 @@ test('detectVsCode: VSCODE_IPC_HOOK_CLI is detected', () => {
49
49
  assert.equal(detectVsCode({ VSCODE_IPC_HOOK_CLI: '/tmp/sock' }), true);
50
50
  });
51
51
 
52
+ test('detectVsCode: VSCODE_HANDLES_SIGPIPE is detected', () => {
53
+ assert.equal(detectVsCode({ VSCODE_HANDLES_SIGPIPE: 'true' }), true);
54
+ });
55
+
52
56
  test('detectVsCode: empty env is not detected', () => {
53
57
  assert.equal(detectVsCode({}), false);
54
58
  });