throughline 0.4.3 → 0.4.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/CHANGELOG.md +12 -0
- package/README.ja.md +5 -1
- package/README.md +24 -21
- package/package.json +1 -1
- package/src/cli/install.mjs +14 -0
- package/src/cli/install.test.mjs +34 -0
- package/src/state-file.mjs +3 -4
- package/src/token-monitor.mjs +62 -22
- package/src/token-monitor.test.mjs +116 -0
- package/src/turn-processor.mjs +2 -3
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,18 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.4.4] — 2026-05-09
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Token monitor now treats Claude transcript and Codex rollout files as live
|
|
18
|
+
inputs. State-file `usage` snapshots remain a fallback, but the display and
|
|
19
|
+
stale hiding no longer wait for Stop hook completion when the live files are
|
|
20
|
+
still changing.
|
|
21
|
+
- `throughline install` now provisions or repairs the current project's VS Code
|
|
22
|
+
`Throughline Monitor` task when running under VS Code / Cursor / VSCodium, so
|
|
23
|
+
monitor auto-start setup no longer depends solely on the first hook event.
|
|
24
|
+
|
|
13
25
|
## [0.4.3] — 2026-05-09
|
|
14
26
|
|
|
15
27
|
### 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` |
|
|
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
|
|
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
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
- **
|
|
564
|
-
persists the latest `tokens / model / contextWindowSize` back into
|
|
565
|
-
file. The monitor prefers
|
|
566
|
-
|
|
567
|
-
the
|
|
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`,
|
|
585
|
-
gets `.vscode/tasks.json` provisioned
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
590
|
-
(SessionStart, UserPromptSubmit, Stop)
|
|
591
|
-
first in your environment creates the file; the rest are idempotent
|
|
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,
|
|
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
package/src/cli/install.mjs
CHANGED
|
@@ -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()) {
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -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) {
|
package/src/state-file.mjs
CHANGED
|
@@ -47,10 +47,9 @@ export function normalizeProjectPath(p) {
|
|
|
47
47
|
* host?: 'claude'|'codex',
|
|
48
48
|
* }} data
|
|
49
49
|
*
|
|
50
|
-
* usage:
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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,
|
package/src/token-monitor.mjs
CHANGED
|
@@ -13,16 +13,18 @@
|
|
|
13
13
|
* - 状態ファイルはセッション単位 (~/.throughline/state/<session_id>.json)
|
|
14
14
|
* - setInterval (1s) + mtime 差分検知で更新を捕捉
|
|
15
15
|
* - updatedAt 降順ソート、先頭行を ▶ でハイライト
|
|
16
|
-
* - stale は
|
|
16
|
+
* - stale は state 更新時刻 + live transcript / rollout mtime で判定
|
|
17
17
|
* - Claude は transcript JSONL の最新 assistant usage を直読
|
|
18
|
-
* - Codex は
|
|
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 の
|
|
399
|
+
* 再描画要否の判定キー。state ファイル群の mtime と transcript / rollout JSONL の
|
|
400
|
+
* size + mtime を
|
|
360
401
|
* 1 本の文字列にまとめてハッシュキーとする。キーが前回と同じなら描画スキップ。
|
|
361
402
|
*
|
|
362
|
-
* 注:
|
|
363
|
-
*
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
-
|
|
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 セル', () => {
|
package/src/turn-processor.mjs
CHANGED
|
@@ -279,9 +279,8 @@ export async function run() {
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
// monitor
|
|
283
|
-
//
|
|
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;
|