throughline 0.3.2 → 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 +65 -7
- 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 +304 -272
- package/src/vscode-task.mjs +240 -0
- package/src/vscode-task.test.mjs +520 -0
package/README.md
CHANGED
|
@@ -178,13 +178,70 @@ 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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
you
|
|
187
|
-
|
|
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.
|
|
194
|
+
|
|
195
|
+
### VS Code auto-start (automatic)
|
|
196
|
+
|
|
197
|
+
After `throughline install`, any VS Code / Cursor / VSCodium project you work in
|
|
198
|
+
gets `.vscode/tasks.json` provisioned automatically on the next assistant turn.
|
|
199
|
+
The file configures `runOn: folderOpen` so the monitor appears in a dedicated
|
|
200
|
+
terminal panel the next time you open that folder.
|
|
201
|
+
|
|
202
|
+
**How it works.** The Stop hook runs at the end of every assistant response.
|
|
203
|
+
Once per project it inspects `.vscode/tasks.json`:
|
|
204
|
+
|
|
205
|
+
- **No file yet** → creates one with a single `Throughline Monitor` task.
|
|
206
|
+
- **Plain JSON with other tasks** → appends the monitor task, preserves your
|
|
207
|
+
existing entries, `version`, and indentation.
|
|
208
|
+
- **JSONC (comments or trailing commas)** → does not touch the file. Prints a
|
|
209
|
+
one-time notice to stderr asking you to paste the snippet below.
|
|
210
|
+
- **Already contains a Throughline Monitor task** → does nothing (idempotent;
|
|
211
|
+
this is the common path on every subsequent turn).
|
|
212
|
+
|
|
213
|
+
The generated task uses `type: 'process'` with the absolute path to Node and
|
|
214
|
+
`bin/throughline.mjs` so Windows `.cmd` shims and missing PATH entries cannot
|
|
215
|
+
break it.
|
|
216
|
+
|
|
217
|
+
**Opt out:** set `THROUGHLINE_NO_VSCODE=1` in the environment used by Claude
|
|
218
|
+
Code. Delete `.vscode/tasks.json` (or just the monitor entry) if you want to
|
|
219
|
+
stop auto-start for a project that already has one.
|
|
220
|
+
|
|
221
|
+
**Manual snippet for JSONC tasks.json files.** If Throughline refused to edit
|
|
222
|
+
your `tasks.json` because it contains comments or trailing commas, add this
|
|
223
|
+
entry to the `tasks` array yourself:
|
|
224
|
+
|
|
225
|
+
```jsonc
|
|
226
|
+
{
|
|
227
|
+
"label": "Throughline Monitor",
|
|
228
|
+
"type": "shell",
|
|
229
|
+
"command": "throughline monitor",
|
|
230
|
+
"isBackground": true,
|
|
231
|
+
"presentation": {
|
|
232
|
+
"reveal": "always",
|
|
233
|
+
"panel": "dedicated",
|
|
234
|
+
"group": "throughline",
|
|
235
|
+
"close": false,
|
|
236
|
+
"echo": false,
|
|
237
|
+
"focus": false,
|
|
238
|
+
"showReuseMessage": false,
|
|
239
|
+
"clear": true
|
|
240
|
+
},
|
|
241
|
+
"runOptions": { "runOn": "folderOpen" },
|
|
242
|
+
"problemMatcher": []
|
|
243
|
+
}
|
|
244
|
+
```
|
|
188
245
|
|
|
189
246
|
---
|
|
190
247
|
|
|
@@ -199,6 +256,7 @@ you open the folder. Drop an equivalent config into your own project's
|
|
|
199
256
|
| `throughline detail <time>` | Retrieve L2 body text and L3 tool I/O for a turn (see below) |
|
|
200
257
|
| `throughline save-inflight` | Called by `/tl` to attach an in-flight memo (stdin) to the current baton |
|
|
201
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 |
|
|
202
260
|
| `throughline status` | Print DB statistics (sessions, skeletons, bodies, details) |
|
|
203
261
|
| `throughline --version` | Print the installed version |
|
|
204
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
|
+
});
|