throughline 0.3.4 → 0.3.6

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 CHANGED
@@ -181,11 +181,14 @@ Example output (real values from a running 1M-context Opus session):
181
181
  - **Resize resilient.** Column width is polled every second, so pane drags that
182
182
  don't fire a terminal `resize` event (common in VS Code's integrated
183
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.
184
+ - **Per-row "last updated" stamp.** Each session row carries an 8-cell
185
+ `just now` / `24m ago` stamp right after the session id, placed before the
186
+ bar so narrow terminals don't truncate it. It resets to `just now` on every
187
+ Stop hook, so a growing stamp means the session is truly idle — not the
188
+ monitor stuck. When you need more detail,
189
+ `throughline doctor --session <id-prefix>` compares the state file against
190
+ the actual transcript JSONL and flags drift, idle time, and
191
+ `/clear`-induced transcript path staleness.
189
192
  - **State-backed usage snapshot.** When the Stop hook finishes a turn it
190
193
  persists the latest `tokens / model / contextWindowSize` back into the state
191
194
  file. The monitor prefers this snapshot over re-reading the JSONL, which
@@ -379,6 +382,13 @@ No session has touched its state file in the last 15 minutes. Send a message in
379
382
  Claude Code and the monitor should pick it up within 1 second. If it still does
380
383
  not, run `throughline doctor`.
381
384
 
385
+ **Monitor seems stuck on the same value**
386
+ Each session row ends with a `(Nm ago)` stamp. If it keeps growing, the session
387
+ is idle — no assistant turn has finished. For a deeper look, run
388
+ `throughline doctor --session <id-prefix>` to compare the state file against
389
+ the actual transcript JSONL and flag drift, idle time, or `/clear`-induced
390
+ transcript path staleness.
391
+
382
392
  **`throughline install` wrote to the wrong settings file**
383
393
  By default, Throughline installs to `~/.claude/settings.json` (user scope, applies
384
394
  to all projects). Use `--project` to scope it to the current directory's
@@ -430,13 +440,18 @@ the folder in VS Code.
430
440
 
431
441
  ## Design docs
432
442
 
433
- - [`docs/L1_L2_L3_REDESIGN.md`](docs/L1_L2_L3_REDESIGN.md) — **current design
443
+ - [`docs/L1_L2_L3_REDESIGN.md`](docs/L1_L2_L3_REDESIGN.md) — **core design
434
444
  spec** for the L1/L2/L3 differential layer model (schema v4 base + v5 L3
435
- classification extension). Authoritative.
436
- - [`docs/PUBLIC_RELEASE_PLAN.md`](docs/PUBLIC_RELEASE_PLAN.md) — public release
437
- plan (CLI surface, package.json layout, § 0 fallback rule)
445
+ classification extension). Authoritative for the memory layering rules.
446
+ - [`docs/INHERITANCE_ON_CLEAR_ONLY.md`](docs/INHERITANCE_ON_CLEAR_ONLY.md) —
447
+ design record for the `/tl` baton handoff system (schema v6–v7). Explains
448
+ why the current inheritance is opt-in rather than heuristic.
449
+ - [`docs/PUBLIC_RELEASE_PLAN.md`](docs/PUBLIC_RELEASE_PLAN.md) — public
450
+ release plan, implementation status by version, § 0 fallback rule, and
451
+ remaining tasks.
438
452
  - [`docs/archive/`](docs/archive/) — superseded design documents kept for
439
- historical reference (original CONCEPT, session-linking experiments, etc.)
453
+ historical reference (original CONCEPT, session-linking experiments,
454
+ pre-publish action list).
440
455
 
441
456
  ---
442
457
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -231,6 +231,30 @@ export function shouldForceFullRedraw(prevCols, currCols) {
231
231
  return prevCols !== currCols && currCols > 0;
232
232
  }
233
233
 
234
+ /**
235
+ * 描画に使う列幅を解決する。
236
+ *
237
+ * 優先順:
238
+ * 1. `process.stdout.columns` が 40 以上 → その値から 1 引いたもの (末尾列での自動改行回避)
239
+ * 2. `process.env.COLUMNS` が 40 以上 → その値
240
+ * 3. それ以外 → 200 にフォールバック(VSCode の `type: process` タスクで stdout が
241
+ * TTY でない場合、columns が undefined または 0/12 のような極端に小さい値を
242
+ * 返すことがあり、そのまま使うと描画が「openclaw」で切れるなどの視覚崩れが出る)
243
+ *
244
+ * 「40 未満は無視」閾値は、現行 formatLine の最小構成(marker + project(18) + id(8) +
245
+ * ago(8) + bar(20) + ...)が実用上 40 セル未満になり得ないため。
246
+ *
247
+ * 固定 200 フォールバックは、truncateToCells が 200 セルより短い実内容を
248
+ * そのまま通す (伸長しない) ので過大でも副作用なし。
249
+ */
250
+ export function resolveColumns() {
251
+ const reported = typeof process.stdout.columns === 'number' ? process.stdout.columns : 0;
252
+ if (reported >= 40) return reported - 1;
253
+ const fromEnv = Number(process.env.COLUMNS);
254
+ if (Number.isFinite(fromEnv) && fromEnv >= 40) return fromEnv - 1;
255
+ return 200;
256
+ }
257
+
234
258
  function formatLine({ state, usage, isActive, now = Date.now() }) {
235
259
  const project = basename(state.projectPath || '?');
236
260
  const shortId = state.sessionId.slice(0, 8);
@@ -263,11 +287,15 @@ function formatLine({ state, usage, isActive, now = Date.now() }) {
263
287
  const modelCol = usage?.model ? color(ANSI.dim, usage.model) : color(ANSI.dim, '(未取得)');
264
288
  // 最終更新からの経過: 表示が「止まって見える」とき、それが idle なのか障害なのかを
265
289
  // 即座に判別できるようにする。updatedAt は state.writeSessionState 時の Date.now()。
266
- const ago = typeof state.updatedAt === 'number'
267
- ? color(ANSI.dim, `(${formatTimeAgo(now - state.updatedAt)})`)
290
+ // 位置は session id の直後(左寄せ固定幅)。狭いターミナルでもモデル名より先に
291
+ // 切れない位置に置くのと、縦の視線移動で「どれが最新更新か」を把握しやすくするため。
292
+ const agoText = typeof state.updatedAt === 'number'
293
+ ? formatTimeAgo(now - state.updatedAt)
268
294
  : '';
295
+ // 8 セル固定: "just now" が最長 (8 セル)、"99d ago" は 7 セル。括弧なしで OK
296
+ const agoCol = color(ANSI.dim, padCellsEnd(agoText, 8));
269
297
 
270
- return `${marker} ${projectCol} ${idCol} ${barCol} ${tokCol} ${remCol} ${modelCol} ${ago}${warn}`;
298
+ return `${marker} ${projectCol} ${idCol} ${agoCol} ${barCol} ${tokCol} ${remCol} ${modelCol}${warn}`;
271
299
  }
272
300
 
273
301
  // --- フィルタ ---
@@ -383,9 +411,7 @@ function renderFrame(args) {
383
411
  // こうすれば ANSI.up(lines.length) と論理行数が物理行数と一致する。
384
412
  // columns - 1 にしてるのはターミナル末尾列に書くと自動改行する端末があるため。
385
413
  // truncateToCells は CJK / 絵文字を 2 セルとして正しく数える。
386
- const columns = process.stdout.columns && process.stdout.columns > 10
387
- ? process.stdout.columns - 1
388
- : 120;
414
+ const columns = resolveColumns();
389
415
  const clipped = lines.map((l) => truncateToCells(l, columns));
390
416
 
391
417
  // 前フレームを消去してから再描画:
@@ -520,6 +546,7 @@ export const _internal = {
520
546
  resetRenderKeyCache,
521
547
  formatTimeAgo,
522
548
  shouldForceFullRedraw,
549
+ resolveColumns,
523
550
  };
524
551
 
525
552
  // --- エントリポイント自動起動 ---
@@ -15,6 +15,7 @@ const {
15
15
  formatLine,
16
16
  formatTimeAgo,
17
17
  shouldForceFullRedraw,
18
+ resolveColumns,
18
19
  } = _internal;
19
20
 
20
21
  // state-file は projectPath を resolve + lowercase 正規化する。
@@ -385,6 +386,58 @@ test('shouldForceFullRedraw: 片方 0 以下は false (未初期化 or 無効)',
385
386
  assert.equal(shouldForceFullRedraw(0, 80), true);
386
387
  });
387
388
 
389
+ // ─── resolveColumns ───────────────────────────────────────────────
390
+
391
+ test('resolveColumns: process.stdout.columns が 40 以上ならそれ - 1 を返す', () => {
392
+ const orig = process.stdout.columns;
393
+ try {
394
+ Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true, writable: true });
395
+ assert.equal(resolveColumns(), 119);
396
+ } finally {
397
+ Object.defineProperty(process.stdout, 'columns', { value: orig, configurable: true, writable: true });
398
+ }
399
+ });
400
+
401
+ test('resolveColumns: columns が小さすぎる値 (12 等) ならフォールバック 200', () => {
402
+ const orig = process.stdout.columns;
403
+ const origEnv = process.env.COLUMNS;
404
+ try {
405
+ Object.defineProperty(process.stdout, 'columns', { value: 12, configurable: true, writable: true });
406
+ delete process.env.COLUMNS;
407
+ assert.equal(resolveColumns(), 200);
408
+ } finally {
409
+ Object.defineProperty(process.stdout, 'columns', { value: orig, configurable: true, writable: true });
410
+ if (origEnv !== undefined) process.env.COLUMNS = origEnv;
411
+ }
412
+ });
413
+
414
+ test('resolveColumns: columns が undefined でも env.COLUMNS >= 40 があればそれを使う', () => {
415
+ const orig = process.stdout.columns;
416
+ const origEnv = process.env.COLUMNS;
417
+ try {
418
+ Object.defineProperty(process.stdout, 'columns', { value: undefined, configurable: true, writable: true });
419
+ process.env.COLUMNS = '150';
420
+ assert.equal(resolveColumns(), 149);
421
+ } finally {
422
+ Object.defineProperty(process.stdout, 'columns', { value: orig, configurable: true, writable: true });
423
+ if (origEnv === undefined) delete process.env.COLUMNS;
424
+ else process.env.COLUMNS = origEnv;
425
+ }
426
+ });
427
+
428
+ test('resolveColumns: 全てのソースが無効ならフォールバック 200', () => {
429
+ const orig = process.stdout.columns;
430
+ const origEnv = process.env.COLUMNS;
431
+ try {
432
+ Object.defineProperty(process.stdout, 'columns', { value: undefined, configurable: true, writable: true });
433
+ delete process.env.COLUMNS;
434
+ assert.equal(resolveColumns(), 200);
435
+ } finally {
436
+ Object.defineProperty(process.stdout, 'columns', { value: orig, configurable: true, writable: true });
437
+ if (origEnv !== undefined) process.env.COLUMNS = origEnv;
438
+ }
439
+ });
440
+
388
441
  // ─── formatLine: time-ago 表示 ────────────────────────────────────
389
442
 
390
443
  test('formatLine: updatedAt から経過時間が表示される', () => {
@@ -395,12 +448,30 @@ test('formatLine: updatedAt から経過時間が表示される', () => {
395
448
  };
396
449
  args.state.updatedAt = now - 3 * 60 * 1000; // 3 分前
397
450
  const out = stripColors(formatLine(args));
398
- assert.ok(out.includes('(3m ago)'), `expected "(3m ago)" in output: ${out}`);
451
+ assert.ok(out.includes('3m ago'), `expected "3m ago" in output: ${out}`);
399
452
  });
400
453
 
401
- test('formatLine: updatedAt が無ければ ago 表示は出ない', () => {
454
+ test('formatLine: updatedAt が無ければ ago テキストは空', () => {
402
455
  const args = makeLineArgs(0.5);
403
456
  delete args.state.updatedAt;
404
457
  const out = stripColors(formatLine(args));
405
458
  assert.ok(!out.includes('ago'));
459
+ assert.ok(!out.includes('just now'));
460
+ });
461
+
462
+ test('formatLine: ago は session id のすぐ右に配置される(狭幅でも切れない位置)', () => {
463
+ const now = Date.now();
464
+ const args = {
465
+ ...makeLineArgs(0.5),
466
+ now,
467
+ };
468
+ args.state.updatedAt = now - 5 * 60 * 1000; // 5m
469
+ const out = stripColors(formatLine(args));
470
+ // shortId と 5m ago の位置関係: shortId が先、ago がその後、bar (█ or ░) がさらに後
471
+ const idPos = out.indexOf('abc12345');
472
+ const agoPos = out.indexOf('5m ago');
473
+ const barPos = out.search(/[█░]/);
474
+ assert.ok(idPos >= 0 && agoPos >= 0 && barPos >= 0, 'all fields present');
475
+ assert.ok(idPos < agoPos, 'shortId before ago');
476
+ assert.ok(agoPos < barPos, 'ago before bar');
406
477
  });
@@ -109,6 +109,12 @@ export function hasMonitorTask(obj) {
109
109
  * 依存して失敗しうる。process は command を直接起動するため堅い。
110
110
  * command には Node 実行ファイルの絶対パスを入れ、args に throughline.mjs の
111
111
  * 絶対パスと 'monitor' サブコマンドを渡す。
112
+ *
113
+ * options.env.COLUMNS=200 を渡す理由:
114
+ * type: 'process' では VSCode は PTY を張らず stdout をパイプするため、
115
+ * 子プロセスの `process.stdout.columns` が undefined か極端に小さい値になり、
116
+ * モニターの幅計算が破綻する。COLUMNS env を明示しておけば token-monitor の
117
+ * resolveColumns() が 200 を採用して行が「openclaw」で切れる不具合を避けられる。
112
118
  */
113
119
  export function buildMonitorTask(throughlineBin) {
114
120
  return {
@@ -117,6 +123,9 @@ export function buildMonitorTask(throughlineBin) {
117
123
  type: 'process',
118
124
  command: process.execPath,
119
125
  args: [throughlineBin, 'monitor'],
126
+ options: {
127
+ env: { COLUMNS: '200' },
128
+ },
120
129
  isBackground: true,
121
130
  presentation: {
122
131
  reveal: 'always',
@@ -148,6 +148,13 @@ test('buildMonitorTask: uses type=process with provided bin as args[0]', () => {
148
148
  assert.equal(task.isBackground, true);
149
149
  });
150
150
 
151
+ test('buildMonitorTask: sets COLUMNS=200 env to work around type:process non-TTY stdout', () => {
152
+ const task = buildMonitorTask('/abs/bin/throughline.mjs');
153
+ assert.ok(task.options, 'task should carry options');
154
+ assert.ok(task.options.env, 'options should carry env');
155
+ assert.equal(task.options.env.COLUMNS, '200');
156
+ });
157
+
151
158
  // --- ensureMonitorTaskFile: skip conditions ---
152
159
 
153
160
  test('ensureMonitorTaskFile: opt_out via THROUGHLINE_NO_VSCODE=1', () => {