@promptctl/cc-candybar 1.4.0 → 1.5.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": "@promptctl/cc-candybar",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,10 +91,10 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.4.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.4.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.4.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.4.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.5.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.5.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.5.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.5.0"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -386,6 +386,31 @@ export const DEFAULT_DSL_CONFIG = {
386
386
  "burn.eta.warnMinutes": { kind: "literal", value: 60 },
387
387
  "burn.eta.errorMinutes": { kind: "literal", value: 30 },
388
388
 
389
+ // Token throughput for the active turn — daemon-derived tok/s on three lanes
390
+ // (render-payload.ts: successive-render delta over the SessionUsageStore).
391
+ // Same absence idiom as burn: -1 is the structurally-impossible default the
392
+ // `formatSpeed` helper reads as "—" [LAW:no-silent-failure] (0 tok/s is a
393
+ // real reading, so it cannot double as the absence marker). Each lane is
394
+ // independently absent — `input` reads "—" mid-stream while `output` flows.
395
+ "speed.input": {
396
+ kind: "input",
397
+ path: "speed.input",
398
+ type: "number",
399
+ default: -1,
400
+ },
401
+ "speed.output": {
402
+ kind: "input",
403
+ path: "speed.output",
404
+ type: "number",
405
+ default: -1,
406
+ },
407
+ "speed.total": {
408
+ kind: "input",
409
+ path: "speed.total",
410
+ type: "number",
411
+ default: -1,
412
+ },
413
+
389
414
  // Context — daemon fetches via ContextProvider; contextLeftPercentage.
390
415
  "context.totalTokens": {
391
416
  kind: "input",
@@ -581,6 +606,23 @@ export const DEFAULT_DSL_CONFIG = {
581
606
  fg: etaHeatFg(".block.etaMinutes", ".burn.eta.warnMinutes"),
582
607
  when: "{{ or (gt .block.resetsAt 0) (gt .weekly.resetsAt 0) }}",
583
608
  },
609
+ // Token throughput for the active turn — output / input / total tok/s, each a
610
+ // successive-render delta computed daemon-side (render-payload.ts); the
611
+ // template only formats. Declared-but-opt-in (NOT in the default root, like
612
+ // block/weekly/burnrate): a user adds `speed` to their layout. Each lane reads
613
+ // "—" when idle/between turns ([LAW:no-silent-failure] — never a stale or
614
+ // divide-by-zero number). Visible once the session has done any work (stable,
615
+ // no layout flicker); `output` is the live generation rate, `input` spikes at
616
+ // turn start, `total` is their sum.
617
+ speed: {
618
+ template:
619
+ ' ⇅ out {{ template "formatSpeed" .speed.output }} · ' +
620
+ 'in {{ template "formatSpeed" .speed.input }} · ' +
621
+ 'tot {{ template "formatSpeed" .speed.total }} ',
622
+ bg: "panel",
623
+ fg: "foreground",
624
+ when: "{{ gt .session.tokens 0 }}",
625
+ },
584
626
  // Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
585
627
  // to 0, so an expired cache renders "cold" (and reads red via the ≤8
586
628
  // arm) rather than a negative number. [LAW:dataflow-not-control-flow]
@@ -759,6 +801,11 @@ export const DEFAULT_DSL_CONFIG = {
759
801
  // daemon could not project (-1 sentinel). Reuses the long-remaining cascade.
760
802
  formatEta:
761
803
  '{{ if lt . 0 }}—{{ else }}{{ template "formatLongTimeRemaining" . }}{{ end }}',
804
+ // Token throughput: "N/s" (K/M-scaled, rounded) when measured (>= 0), "—"
805
+ // when the daemon had no projectable sample (-1). Branches on the VALUE, like
806
+ // formatRate; reuses formatTokenCount so the K/M scale policy has one home.
807
+ formatSpeed:
808
+ '{{ if lt . 0 }}—{{ else }}{{ template "formatTokenCount" (round .) }}/s{{ end }}',
762
809
  // Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
763
810
  // part is formatted by the shared formatTokenCount and joined with " + ". A
764
811
  // `$first` flag (reassigned across if-frames) inserts the separator before all
@@ -47,6 +47,42 @@ export interface TodayInfo {
47
47
  date: string;
48
48
  }
49
49
 
50
+ // [LAW:one-source-of-truth] One observation of the active session's cumulative
51
+ // token counts at a single instant. tok/s is the delta between two of these —
52
+ // the prior sample lives in this store (the single owner of per-session token
53
+ // totals), never in a parallel counter. `input` folds the cache lanes into the
54
+ // prompt-side total so `total === input + output`. `atMs` is the render's clock
55
+ // instant (the daemon's single-enforcer clock), so a frozen test clock makes
56
+ // the rate deterministic.
57
+ export interface SpeedSample {
58
+ readonly input: number;
59
+ readonly output: number;
60
+ readonly total: number;
61
+ readonly atMs: number;
62
+ }
63
+
64
+ // The prior observation (absent on the very first render of a session) and the
65
+ // one just taken. The pure rate projection lives at the render-payload boundary;
66
+ // this store only remembers and reports the pair.
67
+ export interface SpeedObservation {
68
+ readonly prev?: SpeedSample;
69
+ readonly cur: SpeedSample;
70
+ }
71
+
72
+ function speedSampleOf(
73
+ breakdown: TokenBreakdown | null,
74
+ atMs: number,
75
+ ): SpeedSample {
76
+ // Prompt-side = raw input plus both cache lanes (all tokens fed to the model);
77
+ // output = generated. total = the same sum the store's `tokens` projection
78
+ // uses, so `total === input + output`. [LAW:one-source-of-truth]
79
+ const input = breakdown
80
+ ? breakdown.input + breakdown.cacheCreation + breakdown.cacheRead
81
+ : 0;
82
+ const output = breakdown ? breakdown.output : 0;
83
+ return { input, output, total: input + output, atMs };
84
+ }
85
+
50
86
  // Per-(session, day) scalar contribution — the only granularity the today fold
51
87
  // needs. Raw entries are discarded after bucketing, so per-session retained
52
88
  // memory is O(retained-days), not O(entries).
@@ -166,6 +202,17 @@ export class SessionUsageStore {
166
202
  // first seed completes every later read awaits an already-settled promise —
167
203
  // zero rescan. A rejected seed is dropped so the next read retries.
168
204
  private readonly seeded = new Map<string, Promise<void>>();
205
+ // [LAW:one-source-of-truth] The prior tok/s observation per session. tok/s is
206
+ // a derivative of the SAME token totals the records map already owns; the
207
+ // baseline (prev counts + time) lives HERE, not in a parallel counter. One
208
+ // call to observeSpeed advances it; the pure delta math is render-payload's.
209
+ private readonly speedSamples = new Map<string, SpeedSample>();
210
+ // [LAW:no-ambient-temporal-coupling] Explicit owner of observe/commit ordering
211
+ // for the speed sample. Concurrent renders observing the SAME transcript state
212
+ // (key = `${sessionId}:${mtime}`) share ONE observation and commit the baseline
213
+ // exactly once — so they return the same prev+cur and render identical,
214
+ // deterministic throughput instead of the second clobbering the first.
215
+ private readonly speedFlight = new SingleFlight();
169
216
  private readonly maxEntries: number;
170
217
  private readonly staleAgeMs: number;
171
218
  private hits = 0;
@@ -284,6 +331,37 @@ export class SessionUsageStore {
284
331
  });
285
332
  }
286
333
 
334
+ // Take one tok/s observation of the active session: ingest its current
335
+ // cumulative counts, return the prior sample alongside, and record this one as
336
+ // the new baseline. [LAW:no-silent-failure] A failed transcript parse flows out
337
+ // as `failed` (the boundary logs it and the segment reads "—"); an unknown
338
+ // session yields a zero-count sample, so a first-ever render establishes a
339
+ // baseline without fabricating a rate. `nowMs` is the caller's single-enforcer
340
+ // clock instant — the store never reads the clock for tok/s timing itself.
341
+ async observeSpeed(
342
+ sessionId: string,
343
+ transcriptPath: string | undefined,
344
+ nowMs: number,
345
+ ): Promise<Outcome<SpeedObservation>> {
346
+ // [LAW:no-ambient-temporal-coupling] Key the observation by the same
347
+ // (session, mtime) tuple ingest uses, so concurrent renders at one transcript
348
+ // state coalesce onto a single observe-and-commit — the read of `prev` and
349
+ // the write of `cur` happen exactly once for that state, with the flight as
350
+ // the sole owner of ordering. A distinct mtime is a genuinely new sample and
351
+ // gets its own key.
352
+ const mtime = statMtimeMs(transcriptPath);
353
+ return this.speedFlight.run(`${sessionId}:${mtime}`, async () => {
354
+ const record = await this.ingest(sessionId, transcriptPath, mtime);
355
+ if (record.kind === "failed") return record;
356
+ const breakdown =
357
+ record.kind === "ok" ? record.value.sessionInfo.tokenBreakdown : null;
358
+ const cur = speedSampleOf(breakdown, nowMs);
359
+ const prev = this.speedSamples.get(sessionId);
360
+ this.speedSamples.set(sessionId, cur);
361
+ return ok({ ...(prev !== undefined && { prev }), cur });
362
+ });
363
+ }
364
+
287
365
  // mtime-gated, coalesced re-parse of ONE session. `ok` is its record,
288
366
  // `absent` is an unknown empty session (or no sessionId), `failed` is a
289
367
  // transcript that exists but couldn't be parsed — NOT cached (only ok
@@ -416,6 +494,7 @@ export class SessionUsageStore {
416
494
  for (const [sid, record] of this.entries) {
417
495
  if (now - record.lastSeenAt > this.staleAgeMs) {
418
496
  this.entries.delete(sid);
497
+ this.speedSamples.delete(sid);
419
498
  dropped++;
420
499
  }
421
500
  }
@@ -431,6 +510,7 @@ export class SessionUsageStore {
431
510
  const oldest = this.entries.keys().next().value;
432
511
  if (oldest === undefined) break;
433
512
  this.entries.delete(oldest);
513
+ this.speedSamples.delete(oldest);
434
514
  dlog("info", `usageStore evict ${oldest}`);
435
515
  }
436
516
  }
@@ -442,5 +522,6 @@ export class SessionUsageStore {
442
522
  }
443
523
  this.entries.clear();
444
524
  this.seeded.clear();
525
+ this.speedSamples.clear();
445
526
  }
446
527
  }
@@ -25,7 +25,10 @@ import type { GitInfo, GitInfoOptions } from "../segments/git.js";
25
25
  import { ABSENT, failed, type Outcome } from "../utils/outcome.js";
26
26
  import { cacheExpiresAt } from "../segments/cache.js";
27
27
  import type { DaemonLogger } from "./log.js";
28
- import type { SessionUsageStore } from "./cache/session-usage-store.js";
28
+ import type {
29
+ SessionUsageStore,
30
+ SpeedObservation,
31
+ } from "./cache/session-usage-store.js";
29
32
  import type { ContextProvider } from "../segments/context.js";
30
33
  import type { MetricsProvider } from "../segments/metrics.js";
31
34
  import type { TmuxService } from "../segments/tmux.js";
@@ -65,6 +68,7 @@ export interface RenderPayload extends ClaudeHookData {
65
68
  readonly session?: SessionPayload;
66
69
  readonly today?: TodayPayload;
67
70
  readonly burn?: BurnPayload;
71
+ readonly speed?: SpeedPayload;
68
72
  readonly block?: BlockPayload;
69
73
  readonly weekly?: WeeklyPayload;
70
74
  readonly cache?: CachePayload;
@@ -115,6 +119,19 @@ export interface BurnPayload {
115
119
  readonly costPerHour?: number;
116
120
  }
117
121
 
122
+ // [LAW:one-type-per-behavior] Token throughput for the active turn — tokens per
123
+ // second on each of three lanes (prompt-side input, generated output, their
124
+ // total). Each lane is INDEPENDENTLY optional: during streaming `output` moves
125
+ // while `input` (fixed at turn start) is idle, so an absent `input` rate beside a
126
+ // live `output` rate is the honest shape, not a zero. Absence (no baseline yet,
127
+ // idle between turns, or a too-stale prior sample) travels as a missing field to
128
+ // the -1 default, which the `formatSpeed` helper reads as "—". [LAW:no-silent-failure]
129
+ export interface SpeedPayload {
130
+ readonly input?: number;
131
+ readonly output?: number;
132
+ readonly total?: number;
133
+ }
134
+
118
135
  export interface BlockPayload {
119
136
  readonly nativeUtilization: number;
120
137
  readonly resetsAt: number;
@@ -200,6 +217,15 @@ const MIN_PROJECTABLE_ELAPSED_MS = 5 * 60 * 1000;
200
217
  // $/hr is dominated by a single turn rather than a sustained burn.
201
218
  const MIN_BURN_SECONDS = 60;
202
219
 
220
+ // tok/s is a delta between two successive render observations. The wall-time
221
+ // between them must clear a tiny floor (the clock has to have advanced — below
222
+ // it the rate is divide-by-near-zero noise) and stay under a ceiling: a gap
223
+ // wider than this means the prior sample predates an idle stretch, so the rate
224
+ // would be diluted by dead time. Both bounds → no reading (re-baseline silently
225
+ // on the next render) rather than a misleading number. [LAW:no-silent-failure]
226
+ const MIN_SPEED_SAMPLE_MS = 50;
227
+ const MAX_SPEED_SAMPLE_MS = 10 * 1000;
228
+
203
229
  /**
204
230
  * Linearly extrapolate a rate-limit window's utilization to its 100% cap.
205
231
  * The window started `windowMs` before `resetsAtSec`; elapsed time and the
@@ -236,6 +262,64 @@ export function projectCostPerHour(
236
262
  return (cost * 3600) / durationSeconds;
237
263
  }
238
264
 
265
+ /**
266
+ * Instantaneous tokens-per-second between two successive render observations of
267
+ * one cumulative token count. Returns undefined when the sample window is too
268
+ * small or too large to be honest (see the floor/ceiling constants) or when the
269
+ * count did not advance (idle / between turns — a true 0 over a real window is
270
+ * reported as 0, but a flat count carries no throughput to report). A real
271
+ * positive rate is always >= 0, so callers use -1 as the absence default — 0
272
+ * tok/s never doubles as the "no reading" marker. [LAW:no-silent-failure]
273
+ */
274
+ export function projectTokensPerSecond(
275
+ prevTokens: number,
276
+ prevMs: number,
277
+ curTokens: number,
278
+ nowMs: number,
279
+ ): number | undefined {
280
+ const deltaMs = nowMs - prevMs;
281
+ if (deltaMs < MIN_SPEED_SAMPLE_MS || deltaMs > MAX_SPEED_SAMPLE_MS)
282
+ return undefined;
283
+ const deltaTokens = curTokens - prevTokens;
284
+ if (deltaTokens <= 0) return undefined;
285
+ return (deltaTokens * 1000) / deltaMs;
286
+ }
287
+
288
+ // [LAW:effects-at-boundaries] Pure fold of one speed observation into the
289
+ // payload's three rate lanes. No baseline (first render of a session) or every
290
+ // lane un-projectable → undefined (the whole `speed` key is dropped); otherwise
291
+ // each lane that projects contributes its rate, each that doesn't is a missing
292
+ // field → the -1 default → "—".
293
+ function projectSpeed(obs: SpeedObservation): SpeedPayload | undefined {
294
+ const { prev, cur } = obs;
295
+ if (prev === undefined) return undefined;
296
+ const input = projectTokensPerSecond(
297
+ prev.input,
298
+ prev.atMs,
299
+ cur.input,
300
+ cur.atMs,
301
+ );
302
+ const output = projectTokensPerSecond(
303
+ prev.output,
304
+ prev.atMs,
305
+ cur.output,
306
+ cur.atMs,
307
+ );
308
+ const total = projectTokensPerSecond(
309
+ prev.total,
310
+ prev.atMs,
311
+ cur.total,
312
+ cur.atMs,
313
+ );
314
+ if (input === undefined && output === undefined && total === undefined)
315
+ return undefined;
316
+ return {
317
+ ...(input !== undefined && { input }),
318
+ ...(output !== undefined && { output }),
319
+ ...(total !== undefined && { total }),
320
+ };
321
+ }
322
+
239
323
  // ─── Builder ─────────────────────────────────────────────────────────────────
240
324
 
241
325
  // ─── Config-driven provider gating ───────────────────────────────────────────
@@ -428,6 +512,11 @@ export async function buildRenderPayload(
428
512
  const wants = (prefix: string): boolean =>
429
513
  anyPathStartsWith(neededInputPaths, prefix);
430
514
 
515
+ // [LAW:single-enforcer] One clock read feeds every projection this render —
516
+ // the ETA extrapolations below AND the tok/s sample window in the speed lane,
517
+ // so an ETA, a reset countdown, and a throughput figure all agree on "now".
518
+ const nowMs = (deps.clock ?? (() => new Date()))().getTime();
519
+
431
520
  // [LAW:dataflow-not-control-flow][LAW:one-type-per-behavior] Every provider
432
521
  // lane is ONE shape: "needed → call provider (whose contract is to never
433
522
  // reject — the catch makes the lane total against bugs, mapping a throw
@@ -443,40 +532,57 @@ export async function buildRenderPayload(
443
532
  ? run().catch((e: unknown) => failed(`${name}: ${String(e)}`))
444
533
  : Promise.resolve(ABSENT);
445
534
 
446
- const [gitOutcome, usage, today, context, metrics, tmuxSession, cacheExpiry] =
447
- await Promise.all([
448
- lane("git", wants("git"), () =>
449
- deps.gitProvider.getGitInfo(
450
- cwd ?? hookData.workspace?.current_dir,
451
- gitOptionsFromClosure(neededInputPaths),
452
- hookData.workspace?.project_dir,
453
- ),
454
- ),
455
- // [LAW:dataflow-not-control-flow] The burn segment reads `burn.costPerHour`,
456
- // a derivative of session cost and metrics duration — so wanting `burn`
457
- // pulls in exactly the two lanes it is folded from.
458
- lane(
459
- "session",
460
- wants("session.cost") || wants("session.tokens") || wants("burn"),
461
- () => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
535
+ const [
536
+ gitOutcome,
537
+ usage,
538
+ today,
539
+ context,
540
+ metrics,
541
+ tmuxSession,
542
+ cacheExpiry,
543
+ speed,
544
+ ] = await Promise.all([
545
+ lane("git", wants("git"), () =>
546
+ deps.gitProvider.getGitInfo(
547
+ cwd ?? hookData.workspace?.current_dir,
548
+ gitOptionsFromClosure(neededInputPaths),
549
+ hookData.workspace?.project_dir,
462
550
  ),
463
- lane("today", wants("today"), () =>
464
- deps.usageStore.getTodayInfo(hookData),
465
- ),
466
- lane("context", wants("context"), () =>
467
- deps.contextProvider.getContextInfo(hookData),
468
- ),
469
- lane("metrics", wants("metrics") || wants("burn"), () =>
470
- deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
471
- ),
472
- lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
473
- // Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
474
- // seam, so it runs alongside the other providers and stays in the shared
475
- // in-flight budget rather than blocking the event loop on sync fs.
476
- lane("cache", wants("cache"), () =>
477
- cacheExpiresAt(hookData.transcript_path),
551
+ ),
552
+ // [LAW:dataflow-not-control-flow] The burn segment reads `burn.costPerHour`,
553
+ // a derivative of session cost and metrics duration — so wanting `burn`
554
+ // pulls in exactly the two lanes it is folded from.
555
+ lane(
556
+ "session",
557
+ wants("session.cost") || wants("session.tokens") || wants("burn"),
558
+ () => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
559
+ ),
560
+ lane("today", wants("today"), () => deps.usageStore.getTodayInfo(hookData)),
561
+ lane("context", wants("context"), () =>
562
+ deps.contextProvider.getContextInfo(hookData),
563
+ ),
564
+ lane("metrics", wants("metrics") || wants("burn"), () =>
565
+ deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
566
+ ),
567
+ lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
568
+ // Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
569
+ // seam, so it runs alongside the other providers and stays in the shared
570
+ // in-flight budget rather than blocking the event loop on sync fs.
571
+ lane("cache", wants("cache"), () =>
572
+ cacheExpiresAt(hookData.transcript_path),
573
+ ),
574
+ // [LAW:one-source-of-truth] tok/s folds from the SAME store the session
575
+ // lane reads — observeSpeed both reports the prior sample and records this
576
+ // render's, so it must run every render the speed segment is laid out (the
577
+ // first establishes the baseline that the second projects from).
578
+ lane("speed", wants("speed"), () =>
579
+ deps.usageStore.observeSpeed(
580
+ hookData.session_id,
581
+ hookData.transcript_path,
582
+ nowMs,
478
583
  ),
479
- ]);
584
+ ),
585
+ ]);
480
586
  // [LAW:effects-at-boundaries] The projections are pure folds returning data
481
587
  // (payload fragment + failure descriptions); the log effect happens once,
482
588
  // here, at the edge. `take` is the total fold for the single-value lanes:
@@ -505,8 +611,6 @@ export async function buildRenderPayload(
505
611
  // the formatter func — a duplicate code path was retired.)
506
612
  const fiveHour = hookData.rate_limits?.five_hour;
507
613
  const sevenDay = hookData.rate_limits?.seven_day;
508
- // [LAW:single-enforcer] One clock read feeds every projection this render.
509
- const nowMs = (deps.clock ?? (() => new Date()))().getTime();
510
614
  const blockEta = fiveHour
511
615
  ? projectEtaMinutes(
512
616
  fiveHour.used_percentage,
@@ -532,6 +636,13 @@ export async function buildRenderPayload(
532
636
  wants("burn") && burnCost != null && burnDuration != null
533
637
  ? projectCostPerHour(burnCost, burnDuration)
534
638
  : undefined;
639
+ // [LAW:effects-at-boundaries] The store reported the prev+cur samples (an
640
+ // effect: it read state and advanced the baseline); the rate is a pure fold of
641
+ // that data here at the edge. Absent observation (lane skipped/failed) or no
642
+ // projectable lane → no `speed` key → every lane reads its -1 default.
643
+ const speedObs = take(speed);
644
+ const speedPayload =
645
+ speedObs !== undefined ? projectSpeed(speedObs) : undefined;
535
646
 
536
647
  // [LAW:one-source-of-truth] The theme variable surfaces the session's
537
648
  // resolved theme so the toolbar/tray DSL templates can encode it into
@@ -597,6 +708,7 @@ export async function buildRenderPayload(
597
708
  ...(sessionPayload !== undefined && { session: sessionPayload }),
598
709
  ...(todayPayload !== undefined && { today: todayPayload }),
599
710
  ...(costPerHour !== undefined && { burn: { costPerHour } }),
711
+ ...(speedPayload !== undefined && { speed: speedPayload }),
600
712
  ...(wants("block") &&
601
713
  fiveHour !== undefined && {
602
714
  block: {