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