@genee/omp-opsx-addon 0.4.0 → 0.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/index.ts +94 -90
- package/lib/usage-render.ts +5 -2
- package/package.json +1 -1
package/index.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
// ── subagent usage tracking ───────────────────────────────────────────
|
|
2
|
-
// The
|
|
3
|
-
// the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// the
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
// ──
|
|
544
|
-
//
|
|
545
|
-
// for every session, including
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
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
|
-
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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.
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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 {
|
package/lib/usage-render.ts
CHANGED
|
@@ -582,8 +582,11 @@ export function renderConsumptionLines(
|
|
|
582
582
|
for (let li = 0; li < rows; li++) lines[li] += blank;
|
|
583
583
|
continue;
|
|
584
584
|
}
|
|
585
|
-
|
|
586
|
-
|
|
585
|
+
// Minimum visible dot: any non-zero consumption keeps ≥1px no matter
|
|
586
|
+
// how small its ratio to the window max — a low-rate second must
|
|
587
|
+
// never vanish under the dynamic scale.
|
|
588
|
+
const hL = l > 0 ? Math.max(1, Math.round(8 * l)) : 0;
|
|
589
|
+
const hR = r > 0 ? Math.max(1, Math.round(8 * r)) : 0;
|
|
587
590
|
const color = c === accentChar ? 'accent' : consumptionColor((l + r) / 2);
|
|
588
591
|
// Row 0 = top (px 4..8), row 1 = bottom (px 0..4) of each column.
|
|
589
592
|
lines[0] += painter.fg(color, composeChar(Math.max(0, hL - 4), Math.max(0, hR - 4)));
|
package/package.json
CHANGED