useful-pi-extensions 1.8.0 → 1.10.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "useful-pi-extensions",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "A small collection of pi extensions, installed with one command — a labelled status line with context pressure, cache, cost, effort, TTFT and tokens/sec.",
5
5
  "keywords": [
6
6
  "bun",
@@ -55,7 +55,7 @@ Configuration lives at the top of [`render.ts`](render.ts):
55
55
 
56
56
  pi prices every model in USD and its `cost` field carries no unit at all, so the footer cannot know
57
57
  what you were actually billed. The currency and the rate live in `~/.pi/agent/statusline/config.json` — everything this
58
- extension keeps (config plus the reload-proof state file) sits in that one folder:
58
+ extension keeps (config plus the per-session state files that survive /reload) sits in that one folder:
59
59
 
60
60
  ```json
61
61
  {
@@ -18,10 +18,9 @@
18
18
  * stepStartTime; decodeMs = completedTime - firstTokenTime; tok/s = usage.output / (decodeMs / 1000)
19
19
  */
20
20
 
21
- import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
22
- import { stat } from 'node:fs/promises'
21
+ import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
23
22
  import { homedir } from 'node:os'
24
- import { dirname, join } from 'node:path'
23
+ import { basename, dirname, join } from 'node:path'
25
24
 
26
25
  import type {
27
26
  ExtensionAPI,
@@ -71,8 +70,11 @@ const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
71
70
  /** Every session on this machine, for the day-cost total that spans projects and models. */
72
71
  const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions')
73
72
  /** Where throughput metrics wait out a /reload: keyed by session file, so a reload restores. */
74
- const STATE_PATH = join(STATUSLINE_DIR, 'state.json')
73
+ /** One state file per session, so two concurrent sessions cannot clobber each other. */
74
+ const STATE_DIR = join(STATUSLINE_DIR, 'state')
75
75
  const LEGACY_STATE = join(homedir(), '.pi', 'agent', 'statusline-state.json')
76
+ /** The 1.8 single-file state: one bucket every session shared, also migrated to per-session. */
77
+ const LEGACY_SHARED_STATE = join(STATUSLINE_DIR, 'state.json')
76
78
  const FETCH_TIMEOUT_MS = 5000
77
79
 
78
80
  function today(): string {
@@ -133,6 +135,40 @@ async function migrateLegacyFile(legacy: string, current: string): Promise<void>
133
135
  }
134
136
  }
135
137
 
138
+ /** Per-session state path: the session file's name is unique and stable across reloads. */
139
+ function statePath(sessionFile: string): string {
140
+ return join(STATE_DIR, `${basename(sessionFile)}.json`)
141
+ }
142
+
143
+ /** Moves a shared-bucket state file to its per-session name; garbage in, ignored out. */
144
+ async function migrateLegacyState(legacy: string): Promise<void> {
145
+ try {
146
+ const parsed: unknown = JSON.parse(await readFile(legacy, 'utf8'))
147
+ if (!isRecord(parsed) || typeof parsed.sessionFile !== 'string') return
148
+ await mkdir(STATE_DIR, { recursive: true })
149
+ await rename(legacy, statePath(parsed.sessionFile))
150
+ } catch {
151
+ // Nothing to migrate, or the file was not ours: leave it alone.
152
+ }
153
+ }
154
+
155
+ /** Drops per-session state untouched for a week; older files restore nothing useful. */
156
+ async function pruneState(): Promise<void> {
157
+ try {
158
+ const files = await readdir(STATE_DIR)
159
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000
160
+ await Promise.all(
161
+ files.map(async (name) => {
162
+ const filePath = join(STATE_DIR, name)
163
+ const stats = await stat(filePath).catch(() => null)
164
+ if (stats !== null && stats.mtimeMs < cutoff) await unlink(filePath).catch(() => {})
165
+ }),
166
+ )
167
+ } catch {
168
+ // No state dir yet: nothing to prune.
169
+ }
170
+ }
171
+
136
172
  /** Today's provider cost of one session-file line, or null when the line bills nothing today. */
137
173
  function entryCost(line: string, since: number): number | null {
138
174
  if (!line.includes('"usage"')) return null
@@ -414,19 +450,21 @@ export default function (pi: ExtensionAPI) {
414
450
  ttftCount,
415
451
  last: reading,
416
452
  }
417
- void writeFile(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`).catch(() => {})
453
+ void mkdir(STATE_DIR, { recursive: true })
454
+ .then(() => writeFile(statePath(sessionFile), `${JSON.stringify(state, null, 2)}\n`))
455
+ .catch(() => {})
418
456
  }
419
457
 
420
- /** Restores what persist wrote, but only for this exact session file. */
458
+ /** Restores this session's own state file, written by persist before the reload. */
421
459
  async function restore(sessionFile: string | null): Promise<void> {
422
460
  if (sessionFile === null) return
423
461
  let state: unknown
424
462
  try {
425
- state = JSON.parse(await readFile(STATE_PATH, 'utf8'))
463
+ state = JSON.parse(await readFile(statePath(sessionFile), 'utf8'))
426
464
  } catch {
427
465
  return
428
466
  }
429
- if (!isRecord(state) || state.sessionFile !== sessionFile) return
467
+ if (!isRecord(state)) return
430
468
  totalDecodeMs = num(state.totalDecodeMs)
431
469
  totalMeasuredOutput = num(state.totalMeasuredOutput)
432
470
  totalTtftMs = num(state.totalTtftMs)
@@ -483,33 +521,28 @@ export default function (pi: ExtensionAPI) {
483
521
  const separator = theme.fg('dim', ' · ')
484
522
  const wall = theme.fg('dim', ' | ')
485
523
  const identity = [theme.fg('accent', model)]
486
- if (ctx.thinkingLevel) identity.push(pair(theme, 'Effort', ctx.thinkingLevel, 'muted'))
524
+ if (ctx.thinkingLevel) identity.push(pair(theme, 'Effort', ctx.thinkingLevel))
487
525
 
488
526
  const ttft: string[] = []
489
527
  const waiting = ttftDisplay(requestAt, firstTokenAt, Date.now())
490
528
  if (waiting !== null) {
491
529
  // The clock is running: this wait has no reading yet, so the previous turn's
492
530
  // numbers would only be mistaken for the current one.
493
- ttft.push(pair(theme, 'TTFT', waiting.text, 'muted'))
531
+ ttft.push(pair(theme, 'TTFT', waiting.text))
494
532
  } else if (reading) {
495
533
  if (reading.ttftMs !== null)
496
- ttft.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
534
+ ttft.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs)))
497
535
  }
498
536
  const avgTtft = avgMs(totalTtftMs, ttftCount)
499
- if (avgTtft !== null) ttft.push(pair(theme, 'Avg TTFT', formatLatency(avgTtft), 'muted'))
537
+ if (avgTtft !== null) ttft.push(pair(theme, 'Avg TTFT', formatLatency(avgTtft)))
500
538
 
501
539
  const throughput: string[] = []
502
540
  if (reading) {
503
541
  throughput.push(
504
- pair(
505
- theme,
506
- 'Last',
507
- `${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`,
508
- reading.exact ? 'success' : 'dim',
509
- ),
542
+ pair(theme, 'Last', `${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`),
510
543
  )
511
544
  }
512
- if (avg !== null) throughput.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`, 'muted'))
545
+ if (avg !== null) throughput.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`))
513
546
 
514
547
  const row2Right = [identity, ttft, throughput]
515
548
  .filter((group) => group.length > 0)
@@ -579,7 +612,9 @@ export default function (pi: ExtensionAPI) {
579
612
  currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
580
613
  lastKnownUsage = null
581
614
  await migrateLegacyFile(LEGACY_CONFIG, CONFIG_PATH)
582
- await migrateLegacyFile(LEGACY_STATE, STATE_PATH)
615
+ await migrateLegacyState(LEGACY_STATE)
616
+ await migrateLegacyState(LEGACY_SHARED_STATE)
617
+ void pruneState()
583
618
  const file = ctx.sessionManager.getSessionFile()
584
619
  await restore(file ?? null)
585
620
  todayBase = await sumOtherTodaysCost(file ?? null, startOfToday())
@@ -41,7 +41,9 @@ export const ANSI = /\u001b\[[0-9;]*m/g
41
41
  * an unrelated extension that happens to use the same words is left alone.
42
42
  */
43
43
  export const QUIET_STATUS: ReadonlyArray<readonly [string, RegExp]> = [
44
- ['pi-lens-lsp', /^LSP Inactive$/i],
44
+ // pi-lens reports language-server health here. Useful when diagnostics break; permanent
45
+ // noise once they work, so the whole LSP line stays out of the footer.
46
+ ['pi-lens-lsp', /^LSP/i],
45
47
  ]
46
48
 
47
49
  export function formatTokens(count: number): string {