@genee/omp-opsx-addon 0.4.0 → 0.6.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/index.ts CHANGED
@@ -1,19 +1,18 @@
1
1
  // ── subagent usage tracking ───────────────────────────────────────────
2
- // The TASK_SUBAGENT_EVENT_CHANNEL is used to forward subagent events to
3
- // the primary session's eventBus. Subagent message_update/message_end events
4
- // are wrapped in SubagentEventPayload { id, event } and sent through this channel.
5
- // The primary session subscribes to this channel to record subagent usage into
6
- // the primary's recorder. This avoids the module-level singleton issue where
7
- // each plugin instance has its own recorder.
2
+ // The harness forwards every task-subagent message as a SubagentEventPayload
3
+ // { id, event } frame on the "task:subagent:event" channel, emitted to the
4
+ // parent session's eventBus (plus the root observability bus, which is not
5
+ // reachable from extensions). Each plugin instance subscribes its OWN session
6
+ // bus via the official ExtensionAPI handle `pi.events` — the EventBus the
7
+ // harness injects as ConcreteExtensionAPI's `events` field (loader.ts). The
8
+ // root instance's bus carries depth-1 frames, a depth-1 instance's bus carries
9
+ // depth-2 frames, and so on, so every nesting depth is covered while each
10
+ // frame is consumed exactly once. Frames funnel into the module-level recorder
11
+ // singleton below, attributed to the root session id.
8
12
  //
9
- // IMPORTANT: Plugins cannot directly subscribe to eventBus channels via the
10
- // official ExtensionAPI. We access the eventBus through internal properties as a
11
- // pragmatic workaround. The channel string is hardcoded from harness source:
13
+ // The channel string is hardcoded from harness source:
12
14
  // src/task/types.ts:59 -> "task:subagent:event"
13
15
  //
14
- // NOTE: This is a hack that depends on internal harness implementation details.
15
- // Future harness versions may break this if internal properties change.
16
- //
17
16
  // Reference:
18
17
  // - emitSubagentFrame: src/task/executor.ts:1431-1432
19
18
  // - SubagentEventPayload: src/task/types.ts:82-84
@@ -23,42 +22,18 @@ const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event";
23
22
  // Track unsubscribers for cleanup on session_shutdown
24
23
  const subagentEventUnsubscribers = new Map<string, () => void>();
25
24
 
26
- // Helper to safely access eventBus from ctx
27
- function getEventBusFromCtx(ctx: ExtensionContext | ExtensionCommandContext): unknown {
28
- // Try to access eventBus through various internal properties
29
- // This is a hack that depends on harness implementation details
30
- const sessionManager = ctx.sessionManager as Record<string, unknown>;
31
- // Try common property names
32
- for (const key of ['session', '_session', '__session', 'eventBus', '_eventBus']) {
33
- const val = (sessionManager as Record<string, unknown>)[key];
34
- if (val && typeof val === 'object') {
35
- const obj = val as Record<string, unknown>;
36
- if (typeof obj.eventBus !== 'undefined') {
37
- return obj.eventBus;
38
- }
39
- if (key === 'eventBus' || key === '_eventBus') {
40
- return val;
41
- }
42
- }
25
+ // Official session-bus handle accessor. `ExtensionAPI` declares
26
+ // `events: EventBus` and the harness injects the session bus as
27
+ // ConcreteExtensionAPI's `events` field, so this is a typed read; the runtime
28
+ // shape check only defends against older hosts whose dist predates the field.
29
+ function getPluginEventBus(pi: ExtensionAPI): ExtensionAPI['events'] | undefined {
30
+ const events = pi.events;
31
+ if (events && typeof events === 'object' && typeof events.on === 'function') {
32
+ return events;
43
33
  }
44
34
  return undefined;
45
35
  }
46
36
 
47
- // Helper to subscribe to eventBus channel
48
- function subscribeToEventBusChannel(
49
- eventBus: unknown,
50
- channel: string,
51
- handler: (data: unknown) => void,
52
- ): () => void {
53
- const bus = eventBus as Record<string, unknown>;
54
- const onMethod = bus.on as ((channel: string, handler: (data: unknown) => void) => () => void) | undefined;
55
- if (typeof onMethod === 'function') {
56
- return onMethod.call(bus, channel, handler);
57
- }
58
- // Return no-op if eventBus doesn't support subscription
59
- return () => {};
60
- }
61
-
62
37
  // Process subagent event payload
63
38
  function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
64
39
  try {
@@ -540,19 +515,25 @@ function formatFamilyTierMarkdown(sel: SelectionContext): string {
540
515
  return rows.join('\n');
541
516
  }
542
517
 
543
- // ── per-plugin-instance usage recorder ─────────────────────────────
544
- // NOT a process-wide singleton: the harness loads a fresh plugin instance
545
- // for every session, including in-process task subagents (task/executor.ts),
546
- // so each instance builds its own recorder here. Subagent sessions therefore
547
- // cannot write to the primary's recorder directly. Instead their usage is
548
- // captured through the eventBus mechanism:
549
- // - task/executor.ts:1431-1432 forwards subagent events to the primary session's
550
- // eventBus via TASK_SUBAGENT_EVENT_CHANNEL ("task:subagent:event")
551
- // - The primary plugin subscribes to this channel and records subagent usage
552
- // with subagent-specific stream keys (never re-keys the recorder, so the
553
- // owner's cumulative baseline survives and streams don't double-count)
554
- // Each session's recorder persists its own session totals into the shared
555
- // usage cache, which every process aggregates on read.
518
+ // ── process-wide usage recorder singleton ──────────────────────────
519
+ // This IS a process-wide singleton: the harness rebinds the same evaluated
520
+ // plugin module to a fresh ExtensionAPI instance for every session, including
521
+ // in-process task subagents (task/executor.ts), WITHOUT re-evaluating the
522
+ // module graph — so every instance shares the recorder created here. Only the
523
+ // owning session (has a UI, or is the first headless session) re-keys it via
524
+ // setSessionId; rebind instances never re-key and never write usage through
525
+ // their own message_* handlers (gated by recordsOwnUsage below). Subagent
526
+ // consumption reaches this recorder exclusively through the bus-subscription
527
+ // mechanism (see file header):
528
+ // - Each instance subscribes its own session bus (pi.events) to
529
+ // TASK_SUBAGENT_EVENT_CHANNEL ("task:subagent:event"); depth-N frames land
530
+ // on the depth-(N-1) session's bus, so all depths are covered.
531
+ // - Subscription callbacks attribute frames to the recorder's root session id
532
+ // with per-subagent stream keys (stream keys only — the recorder is never
533
+ // re-keyed, so the owner's cumulative baseline survives and streams don't
534
+ // double-count).
535
+ // Session totals persist into the shared usage cache, which every process
536
+ // aggregates on read.
556
537
  const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
557
538
  write: writeSessionUsage,
558
539
  remove: removeSessionUsage,
@@ -697,6 +678,16 @@ export default (pi: ExtensionAPI) => {
697
678
  applyRoleModelOverrides(pi.pi?.settings, sel, warn);
698
679
  };
699
680
 
681
+ // Instance-level usage-record claim: true only while this instance's
682
+ // session owns the process-wide recorder (claimsOwnership in session_start
683
+ // below). Rebind instances (in-process task subagents) keep it false —
684
+ // their message_* handlers must not write the default stream, because that
685
+ // consumption is captured exactly once by the parent instance's bus
686
+ // subscription with a subagent stream key. Closure variable, not
687
+ // module-level: rebind reuses the evaluated module, so a module-level flag
688
+ // would be overwritten by whichever instance starts last.
689
+ let recordsOwnUsage = false;
690
+
700
691
  // ── shared usage poller + widget ──────────────────────────────
701
692
  pi.on('session_start', async (_event, ctx) => {
702
693
  try {
@@ -722,30 +713,38 @@ export default (pi: ExtensionAPI) => {
722
713
  if (claimsOwnership) {
723
714
  sessionUsageRecorder.setSessionId(ctx.sessionManager?.getSessionId?.() ?? '');
724
715
  }
725
- // Subscribe to subagent events via eventBus to capture usage
726
- // Only primary sessions (with UI or no owner) should subscribe
727
- if (claimsOwnership) {
728
- const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
729
- const eventBus = getEventBusFromCtx(ctx);
730
- if (eventBus) {
731
- // Clean up any existing subscription for this session to prevent leaks
732
- const existingUnsubscribe = subagentEventUnsubscribers.get(sessionId);
733
- if (existingUnsubscribe) {
734
- try {
735
- existingUnsubscribe();
736
- } catch {
737
- // Silently ignore errors in unsubscribe
738
- }
739
- }
740
- const unsubscribe = subscribeToEventBusChannel(
741
- eventBus,
742
- TASK_SUBAGENT_EVENT_CHANNEL,
743
- (payload) => handleSubagentEvent(payload, sessionId),
744
- );
745
- if (unsubscribe) {
746
- subagentEventUnsubscribers.set(sessionId, unsubscribe);
716
+ recordsOwnUsage = claimsOwnership;
717
+ // Every instance subscribes its OWN session bus (pi.events) to the
718
+ // subagent channel — record ownership and subscription rights are
719
+ // decoupled: the root instance's bus carries depth-1 frames, a
720
+ // depth-1 instance's bus carries depth-2 frames, and each frame is
721
+ // consumed exactly once (see file header). Callbacks attribute
722
+ // frames to the recorder singleton's root session id (the module
723
+ // graph is shared across rebinds), falling back to this session's
724
+ // own id only before the owner has claimed.
725
+ const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
726
+ const pluginEventBus = getPluginEventBus(pi);
727
+ if (pluginEventBus) {
728
+ // Clean up any existing subscription for this session to prevent leaks
729
+ const existingUnsubscribe = subagentEventUnsubscribers.get(sessionId);
730
+ if (existingUnsubscribe) {
731
+ try {
732
+ existingUnsubscribe();
733
+ } catch {
734
+ // Silently ignore errors in unsubscribe
747
735
  }
748
736
  }
737
+ const unsubscribe = pluginEventBus.on(TASK_SUBAGENT_EVENT_CHANNEL, (payload) => {
738
+ handleSubagentEvent(payload, sessionUsageRecorder.sessionId() || sessionId);
739
+ });
740
+ subagentEventUnsubscribers.set(sessionId, unsubscribe);
741
+ getLogger().info(
742
+ `[omp-opsx-addon] subagent usage capture subscribed (sid=${sessionId}, channel=${TASK_SUBAGENT_EVENT_CHANNEL})`,
743
+ );
744
+ } else {
745
+ getLogger().warn(
746
+ '[omp-opsx-addon] pi.events handle unavailable — subagent usage capture disabled (subagent consumption will not be recorded)',
747
+ );
749
748
  }
750
749
  const auth = ctx.modelRegistry?.authStorage;
751
750
  const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
@@ -981,12 +980,14 @@ export default (pi: ExtensionAPI) => {
981
980
  // PRIMARY usage source, so the waveform reflects in-progress consumption
982
981
  // and stays dense (message_end alone is sparse). `record` is monotonic MAX,
983
982
  // so repeated chunk calls never double-count; the final message_end value
984
- // (equal or higher) is a harmless re-record. In-process subagent sessions
985
- // forward their usage through the eventBus channel (TASK_SUBAGENT_EVENT_CHANNEL)
986
- // which this plugin subscribes to in session_start. Subagent consumption is
987
- // recorded with subagent-specific keys and merged into the primary waveform.
983
+ // (equal or higher) is a harmless re-record. Only the recorder-owning
984
+ // instance writes here: task-subagent rebind instances skip these handlers,
985
+ // because their consumption reaches the recorder through the parent
986
+ // instance's bus subscription instead (subagent-specific stream keys,
987
+ // merged into the primary waveform).
988
988
  pi.on('message_update', async (event) => {
989
989
  try {
990
+ if (!recordsOwnUsage) return;
990
991
  const ev = event.assistantMessageEvent;
991
992
  if ('partial' in ev) {
992
993
  const usage = assistantUsageOf(ev.partial);
@@ -1000,6 +1001,7 @@ export default (pi: ExtensionAPI) => {
1000
1001
  // nonzero streamed total (same monotonic MAX, no double count).
1001
1002
  pi.on('message_end', async (event) => {
1002
1003
  try {
1004
+ if (!recordsOwnUsage) return;
1003
1005
  const usage = assistantUsageOf(event.message);
1004
1006
  if (usage) sessionUsageRecorder.record(canonicalizeProvider(usage.provider), usage.usage);
1005
1007
  } catch {
@@ -1033,13 +1035,15 @@ export default (pi: ExtensionAPI) => {
1033
1035
  }
1034
1036
  }
1035
1037
  // Always clean up eventBus subscription, even if no usage was recorded
1036
- const cleanupUnsubscribe = subagentEventUnsubscribers.get(sid!);
1037
- if (cleanupUnsubscribe) {
1038
- try {
1039
- cleanupUnsubscribe();
1040
- subagentEventUnsubscribers.delete(sid!);
1041
- } catch {
1042
- /* best-effort */
1038
+ if (sid) {
1039
+ const cleanupUnsubscribe = subagentEventUnsubscribers.get(sid);
1040
+ if (cleanupUnsubscribe) {
1041
+ try {
1042
+ cleanupUnsubscribe();
1043
+ subagentEventUnsubscribers.delete(sid);
1044
+ } catch {
1045
+ /* best-effort */
1046
+ }
1043
1047
  }
1044
1048
  }
1045
1049
  } catch {
@@ -48,13 +48,46 @@ export interface Painter {
48
48
  header(label: string, brandAnsi: string): string;
49
49
  /** Numeric balance text (e.g. "余额 ¥18.06"), text-colored. */
50
50
  balance(text: string): string;
51
- /** Foreground-color an arbitrary span with a named color (waveform cells). */
52
- fg(color: PainterColor, text: string): string;
51
+ /**
52
+ * Waveform layer paint — btop row gradient over the provider's brand hue.
53
+ * `brandAnsi` is the raw brand SGR ('' → accent fallback, like header).
54
+ * Both rows share the hue family with a fixed, theme-independent step:
55
+ * 'bottom' a curated 256-color bright shade of the hue (aixterm bright /
56
+ * bold when the code isn't a mapped 16-color SGR), 'top' the plain brand.
57
+ */
58
+ wave(brandAnsi: string, layer: 'top' | 'bottom', text: string): string;
53
59
  }
54
60
 
55
61
  const pctValue = (p: number): string => `${Math.round(p * 100)}%`;
56
62
  const pctColor = (p: number): PainterColor => (p >= 0.85 ? 'error' : p >= 0.6 ? 'warning' : 'success');
57
63
 
64
+ /**
65
+ * Curated 256-color bottom-row shade per 16-color brand hue (chart only —
66
+ * headers keep the raw brand SGR). The 256 palette's 16–255 range renders as
67
+ * FIXED rgb in every terminal, so the bottom/top step is guaranteed visible
68
+ * regardless of the active 16-color theme. Each shade is anchored on the
69
+ * de-facto standard dark-theme value of that hue (TokyoNight family) and is
70
+ * the SAME ink one purity/brightness step up — same hue direction, no hot
71
+ * magenta/red jumps that would break the "one color" read.
72
+ */
73
+ const WAVE_SHADES: Record<string, string> = {
74
+ [X]: '38;5;204', // red #ff5f87 (TN #f7768e, denser)
75
+ [G]: '38;5;114', // green #87d787 (TN #9ece6a, denser)
76
+ [BLUE]: '38;5;75', // blue #5fafff (TN #7aa2f7, denser)
77
+ [PURPLE]: '38;5;177', // purple #d787ff (TN #bb9af7, denser)
78
+ [CYAN]: '38;5;81', // cyan #5fd7ff (TN #7dcfff, denser)
79
+ };
80
+
81
+ /** Aixterm bright variant of a 16-color brand code (`\x1b[3Nm` → `\x1b[9Nm`); undefined when not derivable. */
82
+ const brightVariant = (code: string): string | undefined => {
83
+ const m = code.match(/^\x1b\[3([0-7])m$/);
84
+ return m ? `\x1b[9${m[1]}m` : undefined;
85
+ };
86
+
87
+ /** Bottom-row shade of a raw hue: curated 256 bright shade, aixterm bright, bold as the last step. */
88
+ const bottomShade = (hue: string): string =>
89
+ WAVE_SHADES[hue] ? `\x1b[${WAVE_SHADES[hue]}m` : brightVariant(hue) ?? BOLD + hue;
90
+
58
91
  /** Pre-refactor hardcoded ANSI — the RPC/print fallback and the test baseline. */
59
92
  export const ansiPainter: Painter = {
60
93
  pct: (p) => {
@@ -65,21 +98,7 @@ export const ansiPainter: Painter = {
65
98
  sep: ` ${D}│${R} `,
66
99
  header: (label, brandAnsi) => c(brandAnsi ? BOLD + brandAnsi : BOLD, label),
67
100
  balance: (text) => c(WHITE, text),
68
- fg: (color, text) =>
69
- c(
70
- color === 'error'
71
- ? X
72
- : color === 'warning'
73
- ? Y
74
- : color === 'success'
75
- ? G
76
- : color === 'dim'
77
- ? D
78
- : color === 'accent'
79
- ? CYAN
80
- : WHITE,
81
- text,
82
- ),
101
+ wave: (brandAnsi, layer, text) => c(layer === 'bottom' ? bottomShade(brandAnsi || CYAN) : brandAnsi || CYAN, text),
83
102
  };
84
103
 
85
104
  /** Status colors via the active host theme; brand headers keep raw ANSI. */
@@ -90,7 +109,12 @@ export function themePainter(theme: PainterTheme): Painter {
90
109
  sep: ` ${theme.fg('dim', '│')} `,
91
110
  header: (label, brandAnsi) => (brandAnsi ? c(brandAnsi, label) : theme.fg('accent', label)),
92
111
  balance: (text) => theme.fg('text', text),
93
- fg: (color, text) => theme.fg(color, text),
112
+ wave: (brandAnsi, layer, text) =>
113
+ brandAnsi
114
+ ? c(layer === 'bottom' ? bottomShade(brandAnsi) : brandAnsi, text)
115
+ : layer === 'bottom'
116
+ ? BOLD + theme.fg('accent', text)
117
+ : theme.fg('accent', text),
94
118
  };
95
119
  }
96
120
 
@@ -424,7 +448,7 @@ export function renderUsageReports(
424
448
  // placeholder strip, keeping the column height stable.
425
449
  if (consumptionTracks) {
426
450
  const track = consumptionTracks.get(c.provider);
427
- if (track) c.col.push(...renderConsumptionLines(track.samples, painter, waveCols));
451
+ if (track) c.col.push(...renderConsumptionLines(track.samples, painter, waveCols, providerLabel(c.provider).color));
428
452
  }
429
453
  if (pad) c.col = c.col.map((l) => padRight(l, blockWidth));
430
454
  }
@@ -469,13 +493,6 @@ export function composeChar(leftRows: number, rightRows: number): string {
469
493
  return String.fromCharCode(code);
470
494
  }
471
495
 
472
- /** Named-color band for a stock consumption level (`1 - remaining`): remaining ≥ 50% → success, ≥ 20% → warning, else error. */
473
- export function consumptionColor(c: number): PainterColor {
474
- if (c <= 0.5) return 'success';
475
- if (c <= 0.8) return 'warning';
476
- return 'error';
477
- }
478
-
479
496
  /** One provider's consumption track feeding its embedded mini chart. */
480
497
  export interface ConsumptionTrack {
481
498
  /** Per-interval consumption increments (raw values), newest last. */
@@ -498,12 +515,17 @@ export interface ConsumptionTrack {
498
515
  * window yields a blank placeholder strip, never `undefined`, so every
499
516
  * provider column keeps a stable chart area and the layout never jumps.
500
517
  *
501
- * Color is bound to RATE INTENSITY (image-scaling blend, not quota severity —
502
- * quota urgency already reads in the usage text, e.g. "5h 0%", balance). Each
503
- * Braille character takes the MEAN of its two half-column interpolated
504
- * intensities, mapped via consumptionColor (low→green, mid→yellow, high→red),
505
- * so consecutive chars blend smoothly and never hard-switch. The newest column
506
- * keeps accent.
518
+ * Color is a btop-style ROW gradient bound to the vertical position only —
519
+ * never to the data. The chart paints the provider's brand hue in two layers
520
+ * with a fixed step: the bottom line takes a curated 256-color bright shade
521
+ * of the hue (fixed rgb — visible under any 16-color theme; aixterm bright /
522
+ * bold when the code isn't mapped), the top line the plain brand — visibly
523
+ * stronger at the base, never washed out. Sliding samples reshape the
524
+ * envelope while every
525
+ * pixel's color stays put — the per-column intensity heat bands this
526
+ * replaces flipped green/yellow/red as the window moved, reading as flicker.
527
+ * Providers without a curated brand hue fall back to the theme accent.
528
+ * Quota urgency still reads in the usage text ("5h 0%", balance).
507
529
  *
508
530
  * Geometry unchanged: 1px per sample (half-char step), right-aligned; each
509
531
  * character merges its two half-column samples with max and renders both
@@ -514,6 +536,7 @@ export function renderConsumptionLines(
514
536
  samples: readonly number[],
515
537
  painter: Painter = ansiPainter,
516
538
  cols: number = WAVEFORM_COLS,
539
+ brandAnsi = '',
517
540
  ): string[] {
518
541
  const n = samples.length;
519
542
  const totalPx = cols * 2;
@@ -564,14 +587,10 @@ export function renderConsumptionLines(
564
587
  return plot[m - 1] / max;
565
588
  };
566
589
 
567
- // Each half-column keeps its OWN interpolated height (two samples share one
568
- // char, possibly at different heights — the pair-into-one-char geometry);
569
- // only COLOR is blended (terminals can't half-tint a char, so the char
570
- // takes the mean intensity of its two half-columns).
571
- const accentChar = Math.floor((totalPx - 1) / 2);
572
590
  // Two-line strip: full scale = 8px (2 Braille rows × 4 dot rows). Each
573
- // half-column keeps its OWN interpolated height; only COLOR is blended
574
- // (terminals can't half-tint a char, so the char takes the mean intensity).
591
+ // half-column keeps its OWN interpolated height; COLOR is ROW-bound only
592
+ // (bottom = 256 bright shade of the brand hue, top = plain brand) and
593
+ // never varies per column.
575
594
  const rows = 2;
576
595
  const lines = new Array<string>(rows).fill('');
577
596
  for (let c = 0; c < cols; c++) {
@@ -582,12 +601,14 @@ export function renderConsumptionLines(
582
601
  for (let li = 0; li < rows; li++) lines[li] += blank;
583
602
  continue;
584
603
  }
585
- const hL = Math.round(8 * l);
586
- const hR = Math.round(8 * r);
587
- const color = c === accentChar ? 'accent' : consumptionColor((l + r) / 2);
604
+ // Minimum visible dot: any non-zero consumption keeps ≥1px no matter
605
+ // how small its ratio to the window max — a low-rate second must
606
+ // never vanish under the dynamic scale.
607
+ const hL = l > 0 ? Math.max(1, Math.round(8 * l)) : 0;
608
+ const hR = r > 0 ? Math.max(1, Math.round(8 * r)) : 0;
588
609
  // Row 0 = top (px 4..8), row 1 = bottom (px 0..4) of each column.
589
- lines[0] += painter.fg(color, composeChar(Math.max(0, hL - 4), Math.max(0, hR - 4)));
590
- lines[1] += painter.fg(color, composeChar(Math.min(4, hL), Math.min(4, hR)));
610
+ lines[0] += painter.wave(brandAnsi, 'top', composeChar(Math.max(0, hL - 4), Math.max(0, hR - 4)));
611
+ lines[1] += painter.wave(brandAnsi, 'bottom', composeChar(Math.min(4, hL), Math.min(4, hR)));
591
612
  }
592
613
  return lines;
593
614
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genee/omp-opsx-addon",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Pi Extension: OpenSpec workflow orchestration - coder/reviewer/planner agents, session title & progress",
6
6
  "main": "./index.ts",