throughline 0.3.5 → 0.3.7

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.5",
3
+ "version": "0.3.7",
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,36 @@ export function shouldForceFullRedraw(prevCols, currCols) {
231
231
  return prevCols !== currCols && currCols > 0;
232
232
  }
233
233
 
234
+ /**
235
+ * 描画に使う列幅を解決する。
236
+ *
237
+ * 優先順:
238
+ * 1. **stdout が TTY** かつ `process.stdout.columns` が 40 以上
239
+ * → その値から 1 引いたもの(末尾列での自動改行回避)
240
+ * 2. `process.env.COLUMNS` が 40 以上 → その値 - 1
241
+ * 3. それ以外 → 200 にフォールバック
242
+ *
243
+ * 非 TTY のとき columns を信用しない理由:
244
+ * VSCode の `type: process` タスクは stdout を PTY ではなくパイプとして渡すため、
245
+ * - 起動時の columns が undefined / 0 / 12 のような極端な値になることがある
246
+ * - ターミナル panel をドラッグで広げても SIGWINCH が届かず columns が更新されない
247
+ * - 結果として起動時に狭かった幅のまま永久に truncate され続ける
248
+ * `isTTY` が false の時点で columns 値は信頼できない契約だと割り切り、env.COLUMNS か
249
+ * 固定 200 にフォールバックする。
250
+ *
251
+ * 200 固定フォールバックは、truncateToCells が 200 セル以下の実内容をそのまま通す
252
+ * (伸長しない)ので過大でも副作用なし。
253
+ */
254
+ export function resolveColumns() {
255
+ if (process.stdout.isTTY) {
256
+ const reported = typeof process.stdout.columns === 'number' ? process.stdout.columns : 0;
257
+ if (reported >= 40) return reported - 1;
258
+ }
259
+ const fromEnv = Number(process.env.COLUMNS);
260
+ if (Number.isFinite(fromEnv) && fromEnv >= 40) return fromEnv - 1;
261
+ return 200;
262
+ }
263
+
234
264
  function formatLine({ state, usage, isActive, now = Date.now() }) {
235
265
  const project = basename(state.projectPath || '?');
236
266
  const shortId = state.sessionId.slice(0, 8);
@@ -387,9 +417,7 @@ function renderFrame(args) {
387
417
  // こうすれば ANSI.up(lines.length) と論理行数が物理行数と一致する。
388
418
  // columns - 1 にしてるのはターミナル末尾列に書くと自動改行する端末があるため。
389
419
  // truncateToCells は CJK / 絵文字を 2 セルとして正しく数える。
390
- const columns = process.stdout.columns && process.stdout.columns > 10
391
- ? process.stdout.columns - 1
392
- : 120;
420
+ const columns = resolveColumns();
393
421
  const clipped = lines.map((l) => truncateToCells(l, columns));
394
422
 
395
423
  // 前フレームを消去してから再描画:
@@ -524,6 +552,7 @@ export const _internal = {
524
552
  resetRenderKeyCache,
525
553
  formatTimeAgo,
526
554
  shouldForceFullRedraw,
555
+ resolveColumns,
527
556
  };
528
557
 
529
558
  // --- エントリポイント自動起動 ---
@@ -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,64 @@ test('shouldForceFullRedraw: 片方 0 以下は false (未初期化 or 無効)',
385
386
  assert.equal(shouldForceFullRedraw(0, 80), true);
386
387
  });
387
388
 
389
+ // ─── resolveColumns ───────────────────────────────────────────────
390
+
391
+ function withStdoutState({ isTTY, columns, envColumns }, fn) {
392
+ const origIsTTY = process.stdout.isTTY;
393
+ const origColumns = process.stdout.columns;
394
+ const origEnv = process.env.COLUMNS;
395
+ try {
396
+ Object.defineProperty(process.stdout, 'isTTY', { value: isTTY, configurable: true, writable: true });
397
+ Object.defineProperty(process.stdout, 'columns', { value: columns, configurable: true, writable: true });
398
+ if (envColumns === undefined) delete process.env.COLUMNS;
399
+ else process.env.COLUMNS = envColumns;
400
+ return fn();
401
+ } finally {
402
+ Object.defineProperty(process.stdout, 'isTTY', { value: origIsTTY, configurable: true, writable: true });
403
+ Object.defineProperty(process.stdout, 'columns', { value: origColumns, configurable: true, writable: true });
404
+ if (origEnv === undefined) delete process.env.COLUMNS;
405
+ else process.env.COLUMNS = origEnv;
406
+ }
407
+ }
408
+
409
+ test('resolveColumns: TTY かつ columns が 40 以上 → その値 - 1', () => {
410
+ withStdoutState({ isTTY: true, columns: 120, envColumns: undefined }, () => {
411
+ assert.equal(resolveColumns(), 119);
412
+ });
413
+ });
414
+
415
+ test('resolveColumns: 非 TTY なら columns が大きくても信用しない (env も 200 も使う)', () => {
416
+ // type: process タスクで columns が 120 にセットされたが実際の幅とは連動しない、という状況。
417
+ // env.COLUMNS も無ければ 200 フォールバック。
418
+ withStdoutState({ isTTY: false, columns: 120, envColumns: undefined }, () => {
419
+ assert.equal(resolveColumns(), 200);
420
+ });
421
+ });
422
+
423
+ test('resolveColumns: TTY でも columns が小さすぎる値 (12 等) ならフォールバック 200', () => {
424
+ withStdoutState({ isTTY: true, columns: 12, envColumns: undefined }, () => {
425
+ assert.equal(resolveColumns(), 200);
426
+ });
427
+ });
428
+
429
+ test('resolveColumns: 非 TTY でも env.COLUMNS >= 40 があればそれを使う', () => {
430
+ withStdoutState({ isTTY: false, columns: undefined, envColumns: '150' }, () => {
431
+ assert.equal(resolveColumns(), 149);
432
+ });
433
+ });
434
+
435
+ test('resolveColumns: TTY で columns 未設定、env も無ければフォールバック 200', () => {
436
+ withStdoutState({ isTTY: true, columns: undefined, envColumns: undefined }, () => {
437
+ assert.equal(resolveColumns(), 200);
438
+ });
439
+ });
440
+
441
+ test('resolveColumns: 全てのソースが無効ならフォールバック 200', () => {
442
+ withStdoutState({ isTTY: false, columns: undefined, envColumns: undefined }, () => {
443
+ assert.equal(resolveColumns(), 200);
444
+ });
445
+ });
446
+
388
447
  // ─── formatLine: time-ago 表示 ────────────────────────────────────
389
448
 
390
449
  test('formatLine: updatedAt から経過時間が表示される', () => {
@@ -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', () => {