@genee/omp-opsx-addon 0.3.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/README.md +82 -0
- package/index.ts +248 -154
- package/lib/agent-defs.ts +4 -4
- package/lib/edit-variant-pin.ts +4 -4
- package/lib/logger.ts +33 -0
- package/lib/model-roles.ts +270 -0
- package/lib/pipe-core.ts +784 -0
- package/lib/pipe-push.ts +467 -0
- package/lib/reachability-cache.ts +2 -1
- package/lib/reachability-probe.ts +3 -2
- package/lib/status-probe.ts +4 -3
- package/lib/unified-config.ts +42 -1
- package/lib/usage-poller.ts +208 -7
- package/lib/usage-redis-multiproc.worker.ts +5 -1
- package/lib/usage-render.ts +151 -97
- package/lib/usage-resolver.ts +10 -4
- 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 {
|
|
@@ -104,23 +79,36 @@ function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
|
|
|
104
79
|
sessionUsageRecorder.record(canonicalizeProvider(provider), usage, subagentStreamKey);
|
|
105
80
|
} catch (err) {
|
|
106
81
|
// Silently fail to avoid breaking the plugin
|
|
107
|
-
|
|
82
|
+
getLogger().error('Failed to handle subagent event', { err });
|
|
108
83
|
}
|
|
109
84
|
}
|
|
110
|
-
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@oh-my-pi/pi-coding-agent';
|
|
85
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, Settings } from '@oh-my-pi/pi-coding-agent';
|
|
86
|
+
import { getKnownRoleIds, MODEL_ROLE_IDS } from '@oh-my-pi/pi-coding-agent/config/model-roles';
|
|
111
87
|
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
112
88
|
import type { UsageReport } from '@oh-my-pi/pi-ai';
|
|
113
89
|
import { createCachedScanner } from './lib/change-scanner.js';
|
|
114
90
|
import { installAgents } from './lib/agent-defs.js';
|
|
91
|
+
import {
|
|
92
|
+
applyRoleModelOverrides,
|
|
93
|
+
buildRoleOverridePlan,
|
|
94
|
+
dispatchModelHint,
|
|
95
|
+
frontmatterModel,
|
|
96
|
+
ROLE_TO_OMP_ALIAS,
|
|
97
|
+
OMP_ROLE_TO_TIER,
|
|
98
|
+
ROLE_CAPABILITY,
|
|
99
|
+
type SkippedRole,
|
|
100
|
+
} from './lib/model-roles.js';
|
|
115
101
|
import { buildStaticPrompt, buildDynamicPrompt } from './lib/system-prompt.js';
|
|
116
102
|
import { type ProviderHealth, canonicalizeProvider, listDetectableProviders, healthCacheKey } from './lib/usage-resolver.js';
|
|
117
103
|
import { readOpsxSettings, type ResolvedOpsxConfig, type RoleValue, OPSX_ROLES } from './lib/unified-config.js';
|
|
118
|
-
import { selectAllRoles, mergePlanProviders, type SelectorResult } from './lib/model-selector.js';
|
|
119
|
-
import { scoreModelWithProvider } from './lib/model-tiers.js';
|
|
104
|
+
import { selectAllRoles, selectModel, mergePlanProviders, type SelectorResult } from './lib/model-selector.js';
|
|
105
|
+
import { scoreModelWithProvider, type TierName } from './lib/model-tiers.js';
|
|
120
106
|
import { DIRECT_FETCHERS } from './lib/direct-fetchers.js';
|
|
121
107
|
import { createUsageWidget } from './lib/usage-widget.js';
|
|
122
108
|
import { renderUsageReports, type ConsumptionTrack } from './lib/usage-render.js';
|
|
123
109
|
import { markExhausted, markAvailable, getExhaustedProviders } from './lib/provider-status.js';
|
|
110
|
+
import { registerPipeCore } from './lib/pipe-core.js';
|
|
111
|
+
import { registerPipePush } from './lib/pipe-push.js';
|
|
124
112
|
import { scheduleStatusProbes } from './lib/status-probe.js';
|
|
125
113
|
import {
|
|
126
114
|
startSharedPoller,
|
|
@@ -158,6 +146,7 @@ import { filterSelectableModels, reachabilityFingerprint } from './lib/selection
|
|
|
158
146
|
import { parseAutoModelArgs, CHINA_EXCLUDE_FAMILIES, listFamilies, listProviders } from './lib/family-filter.js';
|
|
159
147
|
import { hasRateLimitError, hasRegionError, buildErrorDiagnosis, exclusionReasonStats } from './lib/error-scan.js';
|
|
160
148
|
import { applyEditVariantPin } from './lib/edit-variant-pin.js';
|
|
149
|
+
import { setOpsxLogger, getLogger } from './lib/logger.js';
|
|
161
150
|
type Role = 'coder' | 'reviewer' | 'planner' | 'proposal-reviewer';
|
|
162
151
|
|
|
163
152
|
/** Session-scoped selector constraint for `/pick-model <selector> [--china]`. */
|
|
@@ -266,24 +255,17 @@ export interface SelectionContext {
|
|
|
266
255
|
roleModels: Record<Role, string | undefined>;
|
|
267
256
|
/** Display label for each role from config, e.g. "auto", "smol", "deepseek-v4-pro". */
|
|
268
257
|
roleConfigs: Record<Role, string | undefined>;
|
|
258
|
+
/** Parsed role value kind for each role (auto | omp-alias | model | provider-model). */
|
|
259
|
+
roleKind: Record<Role, RoleValue['kind']>;
|
|
260
|
+
/** Full-role session overlay payload written to `overrideModelRoles` (all known OMP roles). */
|
|
261
|
+
roleOverrides: Record<string, string>;
|
|
262
|
+
/** Known roles left out of the overlay payload (config skip / capability gate / no pick). */
|
|
263
|
+
skippedRoles: SkippedRole[];
|
|
269
264
|
}
|
|
270
265
|
|
|
271
266
|
const DEFAULT_PLAN_PROVIDERS = new Set(['minimax-code-cn', 'opencode-go', 'zhipu-coding-plan', 'kimi', 'openai-codex']);
|
|
272
267
|
|
|
273
|
-
/** OPSX role → OMP role alias used in agent frontmatter `model: @<alias>`. */
|
|
274
|
-
const ROLE_TO_OMP_ALIAS: Record<Role, string> = {
|
|
275
|
-
coder: 'smol',
|
|
276
|
-
reviewer: 'default',
|
|
277
|
-
planner: 'default',
|
|
278
|
-
'proposal-reviewer': 'default',
|
|
279
|
-
};
|
|
280
268
|
|
|
281
|
-
/** OMP role alias → expected tier (without the `@`). */
|
|
282
|
-
const OMP_ROLE_TO_TIER: Record<string, string> = {
|
|
283
|
-
smol: 'mid',
|
|
284
|
-
default: 'high',
|
|
285
|
-
slow: 'top',
|
|
286
|
-
};
|
|
287
269
|
|
|
288
270
|
function roleValueToModelString(value: RoleValue): string | undefined {
|
|
289
271
|
if (value.kind === 'auto') return undefined;
|
|
@@ -314,19 +296,39 @@ function synthPinnedResult(role: string, modelString: string | undefined): Selec
|
|
|
314
296
|
export function buildDispatchPrompt(role: Role, task: string, selection: SelectionContext): string {
|
|
315
297
|
const agent = AGENT_MAP[role];
|
|
316
298
|
const sel = selection.selectorResults[role];
|
|
317
|
-
|
|
318
|
-
|
|
299
|
+
// auto roles (and omp-alias roles such as @slow) pass NO model: requestModel
|
|
300
|
+
// is the top-priority resolution layer and would shadow the frontmatter
|
|
301
|
+
// tier-role alias. Only concrete provider/model pins stay explicit.
|
|
302
|
+
const modelHint = dispatchModelHint(role, selection);
|
|
303
|
+
const parts = [`你的 opsx.${role} 模型由 role 自动解析。`];
|
|
319
304
|
if (modelHint) parts.push(`请通过 task 工具调用 ${agent} 并指定 model="${modelHint}"。`);
|
|
320
|
-
else parts.push(
|
|
305
|
+
else parts.push(`请直接通过 task 工具调用 ${agent}(无需指定 model,模型按 role 自动解析)。`);
|
|
321
306
|
if (sel?.decision) parts.push(`原因: ${sel.decision}`);
|
|
322
307
|
parts.push('直接用内置工具实现。');
|
|
323
308
|
return `请使用 task 工具委派给 ${agent}:\ntask(agent:"${agent}"${modelHint ? `, model:"${modelHint}"` : ''}, task:"${parts.join(' ')}\\n\\n${task}")`;
|
|
324
309
|
}
|
|
325
310
|
|
|
311
|
+
/**
|
|
312
|
+
* All model roles the host currently knows (10 built-in + custom roles from
|
|
313
|
+
* cycleOrder/modelRoles/modelTags). Host settings can throw during early
|
|
314
|
+
* startup or partial init; fall back to the built-in set rather than
|
|
315
|
+
* aborting selection (later recomputes pick up custom roles once ready).
|
|
316
|
+
*/
|
|
317
|
+
function safeGetKnownRoleIds(settings: Settings | null | undefined): string[] {
|
|
318
|
+
if (!settings) return [...MODEL_ROLE_IDS];
|
|
319
|
+
try {
|
|
320
|
+
return getKnownRoleIds(settings);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
getLogger().warn(`[omp-opsx-addon] getKnownRoleIds failed; using built-in model roles only: ${err instanceof Error ? err.message : String(err)}`);
|
|
323
|
+
return [...MODEL_ROLE_IDS];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
326
327
|
function computeSelectionSync(
|
|
327
328
|
config: ResolvedOpsxConfig,
|
|
328
329
|
models: Model<Api>[] | null,
|
|
329
330
|
health: Map<string, ProviderHealth> | null,
|
|
331
|
+
knownRoleIds: string[],
|
|
330
332
|
regionBlockedModels?: Set<string>,
|
|
331
333
|
unreachableProviders?: Set<string>,
|
|
332
334
|
availableByProvider?: Map<string, string[] | null>,
|
|
@@ -348,16 +350,23 @@ function computeSelectionSync(
|
|
|
348
350
|
const selectorResults = {} as Record<Role, SelectorResult>;
|
|
349
351
|
const roleModels = {} as Record<Role, string | undefined>;
|
|
350
352
|
const roleConfigs = {} as Record<Role, string | undefined>;
|
|
353
|
+
const roleKind = {} as Record<Role, RoleValue['kind']>;
|
|
351
354
|
const planSet = mergePlanProviders(config.planProviders);
|
|
352
355
|
for (const role of OPSX_ROLES) {
|
|
353
356
|
const value = config.roles[role];
|
|
354
|
-
|
|
357
|
+
roleKind[role] = value.kind;
|
|
358
|
+
// Config label shown in /pick-model choices markdown (`角色 (label)`):
|
|
359
|
+
// auto roles display the OMP tier-role alias; pinned roles the pin.
|
|
360
|
+
roleConfigs[role] = value.kind === 'auto'
|
|
361
|
+
? ROLE_TO_OMP_ALIAS[role]
|
|
362
|
+
: (roleValueToModelString(value) ?? '?');
|
|
355
363
|
const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
|
|
356
364
|
if (value.kind === 'auto') {
|
|
357
365
|
if (filteredModels && filteredModels.length > 0) {
|
|
358
366
|
const ompRole = ROLE_TO_OMP_ALIAS[role];
|
|
359
|
-
const tier = (config.roleTiers[role]
|
|
360
|
-
|
|
367
|
+
const tier = (config.roleTiers[role]
|
|
368
|
+
?? (OMP_ROLE_TO_TIER as Record<string, TierName>)[ompRole]
|
|
369
|
+
?? 'mid') as TierName;
|
|
361
370
|
const roleTiers = { ...config.roleTiers, [role]: tier };
|
|
362
371
|
const result = selectAllRoles(
|
|
363
372
|
[role],
|
|
@@ -388,9 +397,56 @@ function computeSelectionSync(
|
|
|
388
397
|
roleModels[role] = modelString?.includes('/') ? modelString : undefined;
|
|
389
398
|
}
|
|
390
399
|
}
|
|
391
|
-
|
|
400
|
+
// change: pick-model-batch-all-roles — build the full-role session overlay:
|
|
401
|
+
// every host-known role (10 built-in + custom) follows one tier's picked
|
|
402
|
+
// model. mid/high reuse the opsx picks (coder/reviewer); tiny/low/top are
|
|
403
|
+
// supplemental picks over the same filtered candidate set; vision picks
|
|
404
|
+
// from image-capable candidates only. See design D2/D3.
|
|
405
|
+
let roleOverrides: Record<string, string> = {};
|
|
406
|
+
let skippedRoles: SkippedRole[] = [];
|
|
407
|
+
if (filteredModels && filteredModels.length > 0) {
|
|
408
|
+
const pickString = (r: SelectorResult): string | undefined =>
|
|
409
|
+
r.picked ? `${r.picked.provider}/${r.picked.id}` : undefined;
|
|
410
|
+
const tierPicks: Partial<Record<TierName, string | undefined>> = {
|
|
411
|
+
mid: pickString(selectorResults.coder),
|
|
412
|
+
high: pickString(selectorResults.reviewer),
|
|
413
|
+
};
|
|
414
|
+
for (const supplementalTier of ['tiny', 'low', 'top'] as const) {
|
|
415
|
+
const synthRole = `__tier_${supplementalTier}`;
|
|
416
|
+
const result = selectModel({
|
|
417
|
+
role: synthRole,
|
|
418
|
+
models: filteredModels,
|
|
419
|
+
roleTiers: { [synthRole]: supplementalTier },
|
|
420
|
+
tierOverrides: config.tierOverrides,
|
|
421
|
+
health: health ?? undefined,
|
|
422
|
+
allowlist: config.modelAllowlist,
|
|
423
|
+
planProviders: planSet,
|
|
424
|
+
});
|
|
425
|
+
tierPicks[supplementalTier] = pickString(result);
|
|
426
|
+
}
|
|
427
|
+
const capabilityPicks: Record<string, string | undefined> = {};
|
|
428
|
+
const visionModels = filteredModels.filter((m) => ROLE_CAPABILITY.vision(m));
|
|
429
|
+
if (visionModels.length > 0) {
|
|
430
|
+
const visionResult = selectModel({
|
|
431
|
+
role: 'vision',
|
|
432
|
+
models: visionModels,
|
|
433
|
+
roleTiers: { vision: 'high' },
|
|
434
|
+
tierOverrides: config.tierOverrides,
|
|
435
|
+
health: health ?? undefined,
|
|
436
|
+
allowlist: config.modelAllowlist,
|
|
437
|
+
planProviders: planSet,
|
|
438
|
+
});
|
|
439
|
+
capabilityPicks.vision = pickString(visionResult);
|
|
440
|
+
}
|
|
441
|
+
const plan = buildRoleOverridePlan(
|
|
442
|
+
{ knownRoleIds, ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks },
|
|
443
|
+
(m) => getLogger().warn(m),
|
|
444
|
+
);
|
|
445
|
+
roleOverrides = plan.overrides;
|
|
446
|
+
skippedRoles = plan.skipped;
|
|
447
|
+
}
|
|
448
|
+
return { selectorResults, roleModels, roleConfigs, roleKind, roleOverrides, skippedRoles };
|
|
392
449
|
}
|
|
393
|
-
|
|
394
450
|
function formatModelChoicesMarkdown(sel: SelectionContext, suffix: string = '', primaryModel?: Model<Api>): string {
|
|
395
451
|
const rows: string[] = [];
|
|
396
452
|
let totalModelCount = 0;
|
|
@@ -459,19 +515,25 @@ function formatFamilyTierMarkdown(sel: SelectionContext): string {
|
|
|
459
515
|
return rows.join('\n');
|
|
460
516
|
}
|
|
461
517
|
|
|
462
|
-
// ──
|
|
463
|
-
//
|
|
464
|
-
// for every session, including
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
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.
|
|
475
537
|
const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
|
|
476
538
|
write: writeSessionUsage,
|
|
477
539
|
remove: removeSessionUsage,
|
|
@@ -481,6 +543,9 @@ const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
|
|
|
481
543
|
// ── plugin entry ────────────────────────────────────────────────────
|
|
482
544
|
|
|
483
545
|
export default (pi: ExtensionAPI) => {
|
|
546
|
+
// Route plugin logging through the host logger (rolling file) instead of
|
|
547
|
+
// console.*, which would corrupt the TUI. Injected before any log path runs.
|
|
548
|
+
setOpsxLogger(pi.logger);
|
|
484
549
|
// Runtime edit-variant pin for deepseek-v4-flash. The host hard-maps the
|
|
485
550
|
// model to the sloppy §/» marker grammar (it misreads hashline ranges),
|
|
486
551
|
// but the model repeatedly breaks sloppy control lines too — retry loops
|
|
@@ -492,7 +557,11 @@ export default (pi: ExtensionAPI) => {
|
|
|
492
557
|
// host reads the merged view directly, making the file equivalent.
|
|
493
558
|
applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
|
|
494
559
|
const directory = process.cwd();
|
|
495
|
-
const warn = (msg: string) =>
|
|
560
|
+
const warn = (msg: string) => pi.logger.warn(msg);
|
|
561
|
+
// Roles known to the host at the moment of a (re)selection: computed per
|
|
562
|
+
// call so custom roles appearing after startup are covered on
|
|
563
|
+
// refresh/reset/selector/tick.
|
|
564
|
+
const currentKnownRoleIds = (): string[] => safeGetKnownRoleIds(pi.pi?.settings);
|
|
496
565
|
const opsxConfig = readOpsxSettings(directory, warn);
|
|
497
566
|
// Static orchestration rules — built once, reused every turn for cache stability.
|
|
498
567
|
const STATIC_PROMPT = buildStaticPrompt();
|
|
@@ -583,6 +652,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
583
652
|
effectiveConfig,
|
|
584
653
|
models,
|
|
585
654
|
health,
|
|
655
|
+
currentKnownRoleIds(),
|
|
586
656
|
regionBlockedModels,
|
|
587
657
|
reach.unreachable,
|
|
588
658
|
reach.availableByProvider,
|
|
@@ -596,32 +666,28 @@ export default (pi: ExtensionAPI) => {
|
|
|
596
666
|
});
|
|
597
667
|
return selectionPromise;
|
|
598
668
|
};
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
for (const role of OPSX_ROLES) {
|
|
610
|
-
const picked = sel.selectorResults[role]?.picked;
|
|
611
|
-
if (picked) {
|
|
612
|
-
overrides[AGENT_MAP[role]] = `${picked.provider}/${picked.id}`;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
if (Object.keys(overrides).length > 0 || force) {
|
|
616
|
-
settings.override('task.agentModelOverrides', overrides);
|
|
617
|
-
warn(
|
|
618
|
-
`[omp-opsx-addon] task.agentModelOverrides: ${Object.entries(overrides)
|
|
619
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
620
|
-
.join(', ')}`,
|
|
621
|
-
);
|
|
622
|
-
}
|
|
669
|
+
// Apply selector picks by TIER to session-scoped OMP modelRoles (smol =
|
|
670
|
+
// coder/mid, default = reviewer/high), so every role-resolving agent —
|
|
671
|
+
// including built-in scout (smol) — follows selector/refresh/reset.
|
|
672
|
+
// Persisted per-agent pins are neutralized at runtime with empty strings.
|
|
673
|
+
// pi.pi.settings is the host-process Settings singleton (the require()'d
|
|
674
|
+
// copy in node_modules is a separate, uninitialized instance — writing to
|
|
675
|
+
// it was a no-op). This thin wrapper is the single call site the
|
|
676
|
+
// constrained-period tick-reselect path (auto-model-family) also uses.
|
|
677
|
+
const applyRoleModel = (sel: SelectionContext): void => {
|
|
678
|
+
applyRoleModelOverrides(pi.pi?.settings, sel, warn);
|
|
623
679
|
};
|
|
624
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
|
+
|
|
625
691
|
// ── shared usage poller + widget ──────────────────────────────
|
|
626
692
|
pi.on('session_start', async (_event, ctx) => {
|
|
627
693
|
try {
|
|
@@ -647,30 +713,38 @@ export default (pi: ExtensionAPI) => {
|
|
|
647
713
|
if (claimsOwnership) {
|
|
648
714
|
sessionUsageRecorder.setSessionId(ctx.sessionManager?.getSessionId?.() ?? '');
|
|
649
715
|
}
|
|
650
|
-
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
);
|
|
670
|
-
if (unsubscribe) {
|
|
671
|
-
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
|
|
672
735
|
}
|
|
673
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
|
+
);
|
|
674
748
|
}
|
|
675
749
|
const auth = ctx.modelRegistry?.authStorage;
|
|
676
750
|
const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
|
|
@@ -906,12 +980,14 @@ export default (pi: ExtensionAPI) => {
|
|
|
906
980
|
// PRIMARY usage source, so the waveform reflects in-progress consumption
|
|
907
981
|
// and stays dense (message_end alone is sparse). `record` is monotonic MAX,
|
|
908
982
|
// so repeated chunk calls never double-count; the final message_end value
|
|
909
|
-
// (equal or higher) is a harmless re-record.
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
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).
|
|
913
988
|
pi.on('message_update', async (event) => {
|
|
914
989
|
try {
|
|
990
|
+
if (!recordsOwnUsage) return;
|
|
915
991
|
const ev = event.assistantMessageEvent;
|
|
916
992
|
if ('partial' in ev) {
|
|
917
993
|
const usage = assistantUsageOf(ev.partial);
|
|
@@ -925,6 +1001,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
925
1001
|
// nonzero streamed total (same monotonic MAX, no double count).
|
|
926
1002
|
pi.on('message_end', async (event) => {
|
|
927
1003
|
try {
|
|
1004
|
+
if (!recordsOwnUsage) return;
|
|
928
1005
|
const usage = assistantUsageOf(event.message);
|
|
929
1006
|
if (usage) sessionUsageRecorder.record(canonicalizeProvider(usage.provider), usage.usage);
|
|
930
1007
|
} catch {
|
|
@@ -958,13 +1035,15 @@ export default (pi: ExtensionAPI) => {
|
|
|
958
1035
|
}
|
|
959
1036
|
}
|
|
960
1037
|
// Always clean up eventBus subscription, even if no usage was recorded
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
+
}
|
|
968
1047
|
}
|
|
969
1048
|
}
|
|
970
1049
|
} catch {
|
|
@@ -980,7 +1059,12 @@ export default (pi: ExtensionAPI) => {
|
|
|
980
1059
|
// This fallback call is intentionally NOT threaded with familyConstraint:
|
|
981
1060
|
// it prefers the cached `selection`, and the fallback passes models=null so
|
|
982
1061
|
// filterSelectableModels' family branch never runs — it is not a reselect path.
|
|
983
|
-
const sel = selection ?? computeSelectionSync(
|
|
1062
|
+
const sel = selection ?? computeSelectionSync(
|
|
1063
|
+
opsxConfig,
|
|
1064
|
+
null,
|
|
1065
|
+
null,
|
|
1066
|
+
safeGetKnownRoleIds(pi.pi?.settings),
|
|
1067
|
+
);
|
|
984
1068
|
const changes = await changeScanner.scanChanges(directory, 'doing');
|
|
985
1069
|
// Layered injection: STATIC_PROMPT is byte-identical every turn → prefix cache
|
|
986
1070
|
// hit for [omp-base + skills + static]. Only the dynamic tail re-prefills.
|
|
@@ -994,17 +1078,14 @@ export default (pi: ExtensionAPI) => {
|
|
|
994
1078
|
|
|
995
1079
|
pi.on('session_start', async (_event, ctx) => {
|
|
996
1080
|
try {
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
if (v.kind === 'auto') return undefined;
|
|
1000
|
-
return roleValueToModelString(v);
|
|
1001
|
-
};
|
|
1081
|
+
const frontmatter = (role: Role): string | undefined =>
|
|
1082
|
+
frontmatterModel(role, opsxConfig.roles[role]);
|
|
1002
1083
|
await installAgents(
|
|
1003
1084
|
directory,
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1085
|
+
frontmatter('coder'),
|
|
1086
|
+
frontmatter('reviewer'),
|
|
1087
|
+
frontmatter('planner'),
|
|
1088
|
+
frontmatter('proposal-reviewer'),
|
|
1008
1089
|
);
|
|
1009
1090
|
warn('[omp-opsx-addon] agents ready (pick-model idle until /pick-model refresh)');
|
|
1010
1091
|
} catch (e) {
|
|
@@ -1020,7 +1101,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1020
1101
|
const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
|
|
1021
1102
|
? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
|
|
1022
1103
|
: undefined;
|
|
1023
|
-
//
|
|
1104
|
+
// Provider exhaustion seen on the response/tool path; mark it locally
|
|
1024
1105
|
// without a usage fetcher — record it in the process-local binary status
|
|
1025
1106
|
// so the placeholder column reads 耗尽 (see lib/provider-status.ts).
|
|
1026
1107
|
markExhausted(canonical);
|
|
@@ -1119,6 +1200,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1119
1200
|
effectiveConfig,
|
|
1120
1201
|
models,
|
|
1121
1202
|
health,
|
|
1203
|
+
currentKnownRoleIds(),
|
|
1122
1204
|
regionBlockedModels,
|
|
1123
1205
|
reach.unreachable,
|
|
1124
1206
|
reach.availableByProvider,
|
|
@@ -1154,7 +1236,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1154
1236
|
}
|
|
1155
1237
|
}
|
|
1156
1238
|
selection = freshSel;
|
|
1157
|
-
|
|
1239
|
+
applyRoleModel(freshSel);
|
|
1158
1240
|
if (changes.length > 0) {
|
|
1159
1241
|
pi.sendMessage({ customType: 'pick-model', content: `✅ 模型已刷新:\n${changes.join('\n')}`, display: true }, { triggerTurn: false });
|
|
1160
1242
|
} else {
|
|
@@ -1181,6 +1263,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1181
1263
|
effectiveConfig,
|
|
1182
1264
|
models,
|
|
1183
1265
|
health,
|
|
1266
|
+
currentKnownRoleIds(),
|
|
1184
1267
|
regionBlockedModels,
|
|
1185
1268
|
reach.unreachable,
|
|
1186
1269
|
reach.availableByProvider,
|
|
@@ -1205,7 +1288,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1205
1288
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
1206
1289
|
selection = sel;
|
|
1207
1290
|
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
|
|
1208
|
-
|
|
1291
|
+
applyRoleModel(sel);
|
|
1209
1292
|
pi.sendMessage({
|
|
1210
1293
|
customType: 'pick-model',
|
|
1211
1294
|
content: changes.length > 0
|
|
@@ -1265,6 +1348,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1265
1348
|
effectiveConfig,
|
|
1266
1349
|
models,
|
|
1267
1350
|
health,
|
|
1351
|
+
currentKnownRoleIds(),
|
|
1268
1352
|
regionBlockedModels,
|
|
1269
1353
|
reach.unreachable,
|
|
1270
1354
|
reach.availableByProvider,
|
|
@@ -1324,7 +1408,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1324
1408
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
1325
1409
|
selection = sel;
|
|
1326
1410
|
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
|
|
1327
|
-
|
|
1411
|
+
applyRoleModel(sel);
|
|
1328
1412
|
const content = `✅ 已切换 selector 约束 (${formatFamilyConstraint(familyConstraint)}):\n${changes.join('\n')}${hint ? `\n${hint}` : ''}`;
|
|
1329
1413
|
pi.sendMessage({
|
|
1330
1414
|
customType: 'pick-model',
|
|
@@ -1379,4 +1463,14 @@ export default (pi: ExtensionAPI) => {
|
|
|
1379
1463
|
if (selection) return selection;
|
|
1380
1464
|
return ensureSelection(ctx);
|
|
1381
1465
|
}
|
|
1466
|
+
|
|
1467
|
+
// Cross-broker project-scoped pipe core: registers opsx_pipe_list /
|
|
1468
|
+
// _send / _recv / _wait and starts presence heartbeat + GC on
|
|
1469
|
+
// session_start (managed timers inside lib/pipe-core).
|
|
1470
|
+
registerPipeCore(pi);
|
|
1471
|
+
|
|
1472
|
+
// Cross-broker push/injector: watches the local mbox slot and injects
|
|
1473
|
+
// inbound pipe messages as custom (opsx-pipe) turns; /opsx-pipe mute
|
|
1474
|
+
// toggles the stop-injection flag (managed timers inside lib/pipe-push).
|
|
1475
|
+
registerPipePush(pi);
|
|
1382
1476
|
};
|