@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/lib/pipe-push.ts
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// ── opsx pipe push ───────────────────────────────────────────────────
|
|
2
|
+
// The push/injection layer ON TOP OF pipe-core. pipe-core owns the on-disk
|
|
3
|
+
// protocol (`.omp/opsx/pipe/` layout, message structure, the four LLM tools,
|
|
4
|
+
// the single shared `consume()` primitive). This module is a READ-ONLY
|
|
5
|
+
// CONSUMER of that protocol:
|
|
6
|
+
//
|
|
7
|
+
// fs.watch(mbox/) doorbell ──┐ (events may coalesce/drop — the watch is
|
|
8
|
+
// 30s fallback tick ─────────┘ only a doorbell; readdir is the truth)
|
|
9
|
+
// → readdir + in-memory seen msg-id diff (idempotent)
|
|
10
|
+
// → read + validate candidate message files
|
|
11
|
+
// → pi.sendMessage(custom payload, { deliverAs, triggerTurn })
|
|
12
|
+
// → on success: core's consume() claims the file (atomic
|
|
13
|
+
// rename→read→delete + msg-id dedup). On inject failure the file
|
|
14
|
+
// is left untouched for the next scan (at-least-once).
|
|
15
|
+
//
|
|
16
|
+
// No self-built queue: steer/followUp/nextTurn ordering and idle wake-ups
|
|
17
|
+
// are entirely the harness's native deliverAs/triggerTurn semantics.
|
|
18
|
+
import { promises as fs, watch } from 'fs';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext } from '@oh-my-pi/pi-coding-agent';
|
|
21
|
+
import {
|
|
22
|
+
pipeRootDir,
|
|
23
|
+
ensureLayouts,
|
|
24
|
+
normalizeDeliver,
|
|
25
|
+
consume,
|
|
26
|
+
type PipeMessage,
|
|
27
|
+
type DeliverMode,
|
|
28
|
+
} from './pipe-core.js';
|
|
29
|
+
|
|
30
|
+
// ── constants ────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Fallback scan cadence; the fs.watch doorbell normally fires instantly. */
|
|
33
|
+
export const PUSH_TICK_MS = 30_000;
|
|
34
|
+
|
|
35
|
+
/** customType for injected pipe messages (traceability, never user-spoofed). */
|
|
36
|
+
export const PIPE_CUSTOM_TYPE = 'opsx-pipe';
|
|
37
|
+
|
|
38
|
+
/** Process-global injector ownership: one push injector per process (the
|
|
39
|
+
* primary session). Mirrors usage-poller's Symbol.for tick-owner claim —
|
|
40
|
+
* subagent sessions also emit session_start in this process but must never
|
|
41
|
+
* start their own watcher/injector. */
|
|
42
|
+
const OWNER_KEY = Symbol.for('omp-opsx-addon.pipe-push.owner');
|
|
43
|
+
|
|
44
|
+
// ── injectable seams (tests; production defaults) ────────────────────
|
|
45
|
+
|
|
46
|
+
export type SendFn = (
|
|
47
|
+
payload: {
|
|
48
|
+
customType: string;
|
|
49
|
+
content: string;
|
|
50
|
+
display: boolean;
|
|
51
|
+
attribution: 'agent';
|
|
52
|
+
details: Record<string, unknown>;
|
|
53
|
+
},
|
|
54
|
+
options: { deliverAs: DeliverMode; triggerTurn: boolean },
|
|
55
|
+
) => void | Promise<void>;
|
|
56
|
+
|
|
57
|
+
/** Minimal structural surface over node/Bun FSWatcher used by the doorbell. */
|
|
58
|
+
export interface WatchHandle {
|
|
59
|
+
close(): void;
|
|
60
|
+
on(event: 'error', cb: (err: unknown) => void): unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type WatchFactory = (path: string, opts: { recursive: boolean }, cb: () => void) => WatchHandle;
|
|
64
|
+
|
|
65
|
+
const nodeWatchFactory: WatchFactory = (path, opts, cb) =>
|
|
66
|
+
watch(path, opts, () => cb()) as unknown as WatchHandle;
|
|
67
|
+
|
|
68
|
+
let rootOverride: string | null = null;
|
|
69
|
+
let watchOverride: WatchFactory | null = null;
|
|
70
|
+
|
|
71
|
+
/** Test seam: redirect the pipe root (defaults to `<ctx.cwd>/.omp/opsx/pipe`). */
|
|
72
|
+
export function _setRootForTest(root: string | null): void {
|
|
73
|
+
rootOverride = root;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Test seam: inject a fake fs.watch factory for deterministic degradation. */
|
|
77
|
+
export function _setWatchFactoryForTest(factory: WatchFactory | null): void {
|
|
78
|
+
watchOverride = factory;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── pure helpers (unit-tested) ───────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fixed last-mile routing template (design D7): when `to.session` names a
|
|
85
|
+
* sub-agent inside the receiving broker, the injected text appends a hub
|
|
86
|
+
* relay instruction, `\n`-separated. Deterministic and exactly assertable.
|
|
87
|
+
*/
|
|
88
|
+
export function injectText(msg: PipeMessage): string {
|
|
89
|
+
return msg.to.session ? `${msg.body}\n经 hub send 转给 ${msg.to.session}` : msg.body;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Structural validation for a message parsed off disk. Mirrors the minimal
|
|
93
|
+
* shape check core's consume() does; `deliver` is re-validated through
|
|
94
|
+
* core's normalizeDeliver at injection time. */
|
|
95
|
+
function isPipeMessage(value: unknown): value is PipeMessage {
|
|
96
|
+
if (!value || typeof value !== 'object') return false;
|
|
97
|
+
const m = value as Record<string, unknown>;
|
|
98
|
+
if (typeof m.id !== 'string' || typeof m.body !== 'string') return false;
|
|
99
|
+
if (!m.from || typeof m.from !== 'object') return false;
|
|
100
|
+
if (!m.to || typeof m.to !== 'object') return false;
|
|
101
|
+
const from = m.from as Record<string, unknown>;
|
|
102
|
+
const to = m.to as Record<string, unknown>;
|
|
103
|
+
return typeof from.broker === 'string' && typeof to.broker === 'string';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── per-broker runtime state ─────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
interface PushState {
|
|
109
|
+
brokerId: string;
|
|
110
|
+
root: string;
|
|
111
|
+
/** Stop-injection flag; default false = injection enabled. */
|
|
112
|
+
muted: boolean;
|
|
113
|
+
watcher?: WatchHandle;
|
|
114
|
+
/** True once the watcher failed; the 30s tick then carries all scans. */
|
|
115
|
+
degraded: boolean;
|
|
116
|
+
tickTimer?: NodeJS.Timeout;
|
|
117
|
+
/** msg-ids already injected (idempotent diff against readdir). */
|
|
118
|
+
seen: Set<string>;
|
|
119
|
+
/** Coalesces concurrent doorbell/tick triggers into one scan chain. */
|
|
120
|
+
scanChain: Promise<unknown>;
|
|
121
|
+
stopped: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const states = new Map<string, PushState>();
|
|
125
|
+
|
|
126
|
+
function getOwner(): string | undefined {
|
|
127
|
+
return (globalThis as Record<symbol, unknown>)[OWNER_KEY] as string | undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function setOwner(brokerId: string | undefined): void {
|
|
131
|
+
(globalThis as Record<symbol, unknown>)[OWNER_KEY] = brokerId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── injection ────────────────────────────────────────────────────────
|
|
135
|
+
/**
|
|
136
|
+
* Inject one message via pi.sendMessage (custom entry, never spoofed as a
|
|
137
|
+
* human). deliver.mode maps 1:1 to deliverAs; triggerTurn is passed through;
|
|
138
|
+
* missing deliver defaults to followUp/true (core normalizeDeliver).
|
|
139
|
+
*/
|
|
140
|
+
async function injectMessage(msg: PipeMessage, send: SendFn): Promise<void> {
|
|
141
|
+
const deliver = normalizeDeliver(msg.deliver);
|
|
142
|
+
await Promise.resolve(
|
|
143
|
+
send(
|
|
144
|
+
{
|
|
145
|
+
customType: PIPE_CUSTOM_TYPE,
|
|
146
|
+
content: injectText(msg),
|
|
147
|
+
display: true,
|
|
148
|
+
attribution: 'agent',
|
|
149
|
+
details: {
|
|
150
|
+
id: msg.id,
|
|
151
|
+
from: msg.from,
|
|
152
|
+
to: msg.to,
|
|
153
|
+
ts: msg.ts,
|
|
154
|
+
...(msg.replyTo ? { replyTo: msg.replyTo } : {}),
|
|
155
|
+
deliver,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{ deliverAs: deliver.mode, triggerTurn: deliver.triggerTurn },
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── the one shared scan (watcher doorbell + fallback tick converge here) ─
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Scan this broker's mbox and inject every not-yet-seen message.
|
|
167
|
+
*
|
|
168
|
+
* Ordering (design D5): list → read/validate → inject → THEN claim via the
|
|
169
|
+
* single shared core `consume()` primitive. A failed injection leaves the
|
|
170
|
+
* file untouched (no claim this scan) so the next scan retries; a crash
|
|
171
|
+
* after injection but before claim redelivers at most once. When any
|
|
172
|
+
* candidate injection fails, the claim step is skipped entirely so failed
|
|
173
|
+
* files are never renamed away.
|
|
174
|
+
*
|
|
175
|
+
* Mute short-circuits BEFORE any filesystem read: no read, no inject, no
|
|
176
|
+
* consume — messages stay in the mbox for the pull tools to recover.
|
|
177
|
+
*/
|
|
178
|
+
export async function scanOnce(state: PushState, send: SendFn): Promise<{ scanned: number; injected: number }> {
|
|
179
|
+
if (state.stopped || state.muted) return { scanned: 0, injected: 0 };
|
|
180
|
+
|
|
181
|
+
const dir = join(state.root, 'mbox', state.brokerId);
|
|
182
|
+
const before = await listMessageIds(dir);
|
|
183
|
+
if (before === null) return { scanned: 0, injected: 0 }; // mailbox slot not created yet
|
|
184
|
+
|
|
185
|
+
const beforeSet = new Set(before);
|
|
186
|
+
const candidates = before.filter((id) => !state.seen.has(id)).sort();
|
|
187
|
+
|
|
188
|
+
let injected = 0;
|
|
189
|
+
/** Set only by INJECTION failures: those files must stay for retry.
|
|
190
|
+
* Corrupt/unreadable files are NOT retentions — core's consume()
|
|
191
|
+
* renames them aside and drops them, so they never block the claim. */
|
|
192
|
+
let injectFailed = false;
|
|
193
|
+
|
|
194
|
+
for (const id of candidates) {
|
|
195
|
+
const msg = await readMessage(join(dir, `${id}.json`));
|
|
196
|
+
if (!msg) continue; // corrupt: removed by the claim step, never injected
|
|
197
|
+
state.seen.add(id);
|
|
198
|
+
try {
|
|
199
|
+
await injectMessage(msg, send);
|
|
200
|
+
injected += 1;
|
|
201
|
+
} catch {
|
|
202
|
+
// Injection failed: do NOT claim — the file stays for the next
|
|
203
|
+
// scan, and the id is unmarked so it is retried.
|
|
204
|
+
state.seen.delete(id);
|
|
205
|
+
injectFailed = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Claim via core's single consume() primitive. consume() claims the WHOLE
|
|
210
|
+
// slot (rename→read→delete for every .json), so:
|
|
211
|
+
// 1. an injection failure this scan skips the claim entirely (retry),
|
|
212
|
+
// 2. the claim runs only while the listing is STABLE — a file that
|
|
213
|
+
// races in during the inject pass is swept unseen otherwise. When
|
|
214
|
+
// names changed, the next scan injects the newcomer and claims once
|
|
215
|
+
// settled (already-seen ids are not injected twice).
|
|
216
|
+
if (!injectFailed) {
|
|
217
|
+
const after = await listMessageIds(dir);
|
|
218
|
+
// Stable = the exact same name set as at scan start (no newcomer
|
|
219
|
+
// racing in, no pull-tool consumption racing out). Corrupt files
|
|
220
|
+
// count as settled: consume() drops them.
|
|
221
|
+
const stable = after !== null && after.length === before.length && after.every((id) => beforeSet.has(id));
|
|
222
|
+
if (stable) {
|
|
223
|
+
const claimed = await consume(state.root, state.brokerId);
|
|
224
|
+
for (const m of claimed) {
|
|
225
|
+
if (state.seen.has(m.id)) continue;
|
|
226
|
+
// Not injected because our own read failed transiently; the
|
|
227
|
+
// file is valid (consume parsed it) — deliver it now.
|
|
228
|
+
state.seen.add(m.id);
|
|
229
|
+
try {
|
|
230
|
+
await injectMessage(m, send);
|
|
231
|
+
injected += 1;
|
|
232
|
+
} catch {
|
|
233
|
+
state.seen.delete(m.id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { scanned: candidates.length, injected };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** List settled message ids in a broker mbox; null when the slot is absent. */
|
|
243
|
+
async function listMessageIds(dir: string): Promise<string[] | null> {
|
|
244
|
+
try {
|
|
245
|
+
const names = await fs.readdir(dir);
|
|
246
|
+
return names.filter((n) => n.endsWith('.json')).map((n) => n.slice(0, -'.json'.length));
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Read + validate one message file; corrupt/unreadable → null. */
|
|
253
|
+
async function readMessage(file: string): Promise<PipeMessage | null> {
|
|
254
|
+
try {
|
|
255
|
+
const parsed = JSON.parse(await fs.readFile(file, 'utf-8')) as unknown;
|
|
256
|
+
return isPipeMessage(parsed) ? parsed : null;
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function triggerScan(state: PushState, send: SendFn): void {
|
|
263
|
+
state.scanChain = state.scanChain
|
|
264
|
+
.then(() => scanOnce(state, send))
|
|
265
|
+
.catch(() => {
|
|
266
|
+
/* scan failures must never kill the timer/watcher chain */
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── lifecycle ────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Start the push injector for the primary session of `ctx`. Idempotent per
|
|
274
|
+
* broker; no-ops for secondary sessions in a process that already has an
|
|
275
|
+
* owner (subagents emit session_start in-process but never inject).
|
|
276
|
+
*/
|
|
277
|
+
export async function startPipePush(
|
|
278
|
+
ctx: ExtensionContext,
|
|
279
|
+
send: SendFn,
|
|
280
|
+
opts?: { watchFactory?: WatchFactory; tickMs?: number },
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
|
|
283
|
+
if (!brokerId || states.has(brokerId)) return;
|
|
284
|
+
const owner = getOwner();
|
|
285
|
+
if (owner && owner !== brokerId) return;
|
|
286
|
+
|
|
287
|
+
const root = rootOverride ?? pipeRootDir(ctx.cwd);
|
|
288
|
+
await ensureLayouts(root);
|
|
289
|
+
// Make sure our own slot exists before watching so doorbell events on
|
|
290
|
+
// newly-landed files fire in a directory that already exists.
|
|
291
|
+
await fs.mkdir(join(root, 'mbox', brokerId), { recursive: true });
|
|
292
|
+
|
|
293
|
+
const state: PushState = {
|
|
294
|
+
brokerId,
|
|
295
|
+
root,
|
|
296
|
+
muted: false,
|
|
297
|
+
degraded: false,
|
|
298
|
+
seen: new Set(),
|
|
299
|
+
scanChain: Promise.resolve(),
|
|
300
|
+
stopped: false,
|
|
301
|
+
};
|
|
302
|
+
states.set(brokerId, state);
|
|
303
|
+
setOwner(brokerId);
|
|
304
|
+
|
|
305
|
+
const tickMs = opts?.tickMs ?? PUSH_TICK_MS;
|
|
306
|
+
state.tickTimer = ctx.setInterval(() => triggerScan(state, send), tickMs);
|
|
307
|
+
|
|
308
|
+
// Doorbell: fs.watch over the whole mbox/ tree (recursive on macOS per
|
|
309
|
+
// the Bun/macOS spike). Construction failures (e.g. ENOENT) throw
|
|
310
|
+
// synchronously on Bun; error events arrive asynchronously. Either way
|
|
311
|
+
// we degrade to pure polling — the tick shares the exact same scan.
|
|
312
|
+
const factory = opts?.watchFactory ?? watchOverride ?? nodeWatchFactory;
|
|
313
|
+
try {
|
|
314
|
+
const watcher = factory(join(root, 'mbox'), { recursive: true }, () => triggerScan(state, send));
|
|
315
|
+
watcher.on('error', () => {
|
|
316
|
+
state.degraded = true;
|
|
317
|
+
try {
|
|
318
|
+
watcher.close();
|
|
319
|
+
} catch {
|
|
320
|
+
/* best-effort */
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
state.watcher = watcher;
|
|
324
|
+
} catch {
|
|
325
|
+
state.degraded = true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Immediate scan: drains a backlog after restart (crash-redelivery) and
|
|
329
|
+
// mirrors core's immediate heartbeat — never wait for the first tick.
|
|
330
|
+
triggerScan(state, send);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Stop the injector (session_shutdown): close watcher, clear managed timer. */
|
|
334
|
+
export async function stopPipePush(ctx: ExtensionContext): Promise<void> {
|
|
335
|
+
const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
|
|
336
|
+
const state = brokerId ? states.get(brokerId) : undefined;
|
|
337
|
+
if (!state) return;
|
|
338
|
+
state.stopped = true;
|
|
339
|
+
states.delete(brokerId);
|
|
340
|
+
if (getOwner() === brokerId) setOwner(undefined);
|
|
341
|
+
try {
|
|
342
|
+
state.watcher?.close();
|
|
343
|
+
} catch {
|
|
344
|
+
/* best-effort */
|
|
345
|
+
}
|
|
346
|
+
if (state.tickTimer) ctx.clearTimer(state.tickTimer);
|
|
347
|
+
await state.scanChain.catch(() => {});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Set the stop-injection flag for a broker (the /opsx-pipe mute command). */
|
|
351
|
+
export function setMuted(brokerId: string, muted: boolean): void {
|
|
352
|
+
const state = states.get(brokerId);
|
|
353
|
+
if (state) state.muted = muted;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function isMuted(brokerId: string): boolean {
|
|
357
|
+
return states.get(brokerId)?.muted ?? false;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── plugin wiring ────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
export function registerPipePush(pi: ExtensionAPI): void {
|
|
363
|
+
pi.registerCommand('opsx-pipe', {
|
|
364
|
+
description:
|
|
365
|
+
'跨 broker pipe 推送开关:/opsx-pipe mute 翻转停注标志(默认注入开启,mute 后消息保留在 mbox 可用 opsx_pipe_recv/wait 拉取);无参数显示当前状态。',
|
|
366
|
+
handler: async (args: string, ctx) => {
|
|
367
|
+
const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
|
|
368
|
+
const state = brokerId ? states.get(brokerId) : undefined;
|
|
369
|
+
const arg = args.trim();
|
|
370
|
+
if (arg === 'mute') {
|
|
371
|
+
if (!state) {
|
|
372
|
+
// push 尚未初始化(session_start 未完成或启动失败):不能谎报翻转结果
|
|
373
|
+
pi.sendMessage(
|
|
374
|
+
{
|
|
375
|
+
customType: PIPE_CUSTOM_TYPE,
|
|
376
|
+
content:
|
|
377
|
+
'⚠️ opsx-pipe 推送尚未启动(初始化未完成或失败),停注开关暂不可用。到达的消息仍可通过 opsx_pipe_recv/opsx_pipe_wait 拉取。',
|
|
378
|
+
display: true,
|
|
379
|
+
attribution: 'agent',
|
|
380
|
+
},
|
|
381
|
+
{ triggerTurn: false },
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
state.muted = !state.muted;
|
|
386
|
+
pi.sendMessage(
|
|
387
|
+
{
|
|
388
|
+
customType: PIPE_CUSTOM_TYPE,
|
|
389
|
+
content: state.muted
|
|
390
|
+
? '🔇 opsx-pipe 推送已停注(mute):消息保留在 mbox,可用 opsx_pipe_recv/opsx_pipe_wait 拉取。再次 /opsx-pipe mute 恢复注入。'
|
|
391
|
+
: '🔈 opsx-pipe 推送注入已开启。',
|
|
392
|
+
display: true,
|
|
393
|
+
attribution: 'agent',
|
|
394
|
+
},
|
|
395
|
+
{ triggerTurn: false },
|
|
396
|
+
);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
pi.sendMessage(
|
|
400
|
+
{
|
|
401
|
+
customType: PIPE_CUSTOM_TYPE,
|
|
402
|
+
content: `opsx-pipe 状态:${state?.muted ? '🔇 已停注(mute)' : '🔈 注入开启'}${
|
|
403
|
+
state?.degraded ? ';watcher 不可用,30s 兜底轮询中' : ''
|
|
404
|
+
}。用法:/opsx-pipe mute 翻转停注开关。`,
|
|
405
|
+
display: true,
|
|
406
|
+
attribution: 'agent',
|
|
407
|
+
},
|
|
408
|
+
{ triggerTurn: false },
|
|
409
|
+
);
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
414
|
+
try {
|
|
415
|
+
await startPipePush(ctx, (payload, options) => pi.sendMessage(payload, options));
|
|
416
|
+
} catch {
|
|
417
|
+
/* pipe push is best-effort; never break session start */
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
pi.on('session_shutdown', async (_event, ctx) => {
|
|
421
|
+
try {
|
|
422
|
+
await stopPipePush(ctx);
|
|
423
|
+
} catch {
|
|
424
|
+
/* best-effort */
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── test-only reset ──────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
/** Test seam: clear all runtime state, ownership, and overrides. */
|
|
432
|
+
export function _resetForTest(): void {
|
|
433
|
+
states.clear();
|
|
434
|
+
rootOverride = null;
|
|
435
|
+
watchOverride = null;
|
|
436
|
+
setOwner(undefined);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Test seam: inspect a broker's push state (degraded flag, seen set). */
|
|
440
|
+
export function _getStateForTest(brokerId: string): PushState | undefined {
|
|
441
|
+
return states.get(brokerId);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Test seam: await the settled scan chain (doorbell/tick scans are async). */
|
|
445
|
+
export async function _flushForTest(brokerId: string): Promise<void> {
|
|
446
|
+
const state = states.get(brokerId);
|
|
447
|
+
if (state) await state.scanChain.catch(() => {});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Test seam: create a quiescent push state (no timer/watcher/auto-scan) so
|
|
451
|
+
* scanOnce logic can be driven deterministically without the lifecycle. */
|
|
452
|
+
export async function _makeStateForTest(brokerId: string, cwd: string): Promise<PushState> {
|
|
453
|
+
const root = rootOverride ?? pipeRootDir(cwd);
|
|
454
|
+
await ensureLayouts(root);
|
|
455
|
+
await fs.mkdir(join(root, 'mbox', brokerId), { recursive: true });
|
|
456
|
+
const state: PushState = {
|
|
457
|
+
brokerId,
|
|
458
|
+
root,
|
|
459
|
+
muted: false,
|
|
460
|
+
degraded: true, // no watcher in this harness
|
|
461
|
+
seen: new Set(),
|
|
462
|
+
scanChain: Promise.resolve(),
|
|
463
|
+
stopped: false,
|
|
464
|
+
};
|
|
465
|
+
states.set(brokerId, state);
|
|
466
|
+
return state;
|
|
467
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
18
18
|
import { join, dirname } from 'node:path';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
|
+
import { getLogger } from './logger.js';
|
|
20
21
|
|
|
21
22
|
// ── 缓存契约 ─────────────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -132,7 +133,7 @@ export function writeReachabilityCache(cache: ReachabilityCacheFile): void {
|
|
|
132
133
|
ensureDir(dirname(path));
|
|
133
134
|
writeFileSync(path, JSON.stringify(cache, null, 2), 'utf-8');
|
|
134
135
|
} catch (e) {
|
|
135
|
-
|
|
136
|
+
getLogger().warn(`[omp-opsx-addon] failed to write reachability cache: ${e}`);
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
@@ -18,6 +18,7 @@ import { connect as tlsConnect } from 'node:tls';
|
|
|
18
18
|
import type { TLSSocket } from 'node:tls';
|
|
19
19
|
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
20
20
|
import { resolveCursorAccessToken } from './cursor-auth.js';
|
|
21
|
+
import { getLogger } from './logger.js';
|
|
21
22
|
|
|
22
23
|
// ── 类型 ────────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -424,7 +425,7 @@ export async function probeProviders(
|
|
|
424
425
|
|
|
425
426
|
for (const s of settled) {
|
|
426
427
|
if (s.status === 'rejected') {
|
|
427
|
-
|
|
428
|
+
getLogger().warn(`[omp-opsx-addon] probe error: ${s.reason}`);
|
|
428
429
|
continue;
|
|
429
430
|
}
|
|
430
431
|
const [provider, result] = s.value;
|
|
@@ -439,7 +440,7 @@ export async function probeProviders(
|
|
|
439
440
|
}
|
|
440
441
|
|
|
441
442
|
if (providerReasons.length > 0) {
|
|
442
|
-
|
|
443
|
+
getLogger().warn(`[omp-opsx-addon] unreachable endpoints:\n ${providerReasons.join('\n ')}`);
|
|
443
444
|
}
|
|
444
445
|
|
|
445
446
|
// 构建返回结果
|
package/lib/status-probe.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { REGION_BLOCK_TOKENS } from './reachability-probe.js';
|
|
22
22
|
import { canonicalizeProvider } from './usage-resolver.js';
|
|
23
23
|
import { markExhausted, markAvailable } from './provider-status.js';
|
|
24
|
+
import { getLogger } from './logger.js';
|
|
24
25
|
|
|
25
26
|
// ── 类型 ────────────────────────────────────────────────────────────────
|
|
26
27
|
|
|
@@ -269,14 +270,14 @@ export async function scheduleStatusProbes(opts: ScheduleOptions): Promise<numbe
|
|
|
269
270
|
if (probeResult.class === 'exhausted' || probeResult.class === 'region-blocked') {
|
|
270
271
|
markExhausted(probeResult.provider);
|
|
271
272
|
written++;
|
|
272
|
-
|
|
273
|
+
getLogger().info(`[omp-opsx-addon] status probe: ${canonical} -> ${probeResult.class}`);
|
|
273
274
|
} else if (probeResult.class === 'ok') {
|
|
274
275
|
markAvailable(probeResult.provider);
|
|
275
276
|
written++;
|
|
276
|
-
|
|
277
|
+
getLogger().info(`[omp-opsx-addon] status probe: ${canonical} -> ok`);
|
|
277
278
|
} else {
|
|
278
279
|
// auth-error/model-not-found/network-error 不写状态
|
|
279
|
-
|
|
280
|
+
getLogger().warn(
|
|
280
281
|
`[omp-opsx-addon] status probe: ${canonical} -> ${probeResult.class}${probeResult.detail ? ` (${probeResult.detail})` : ''}`,
|
|
281
282
|
);
|
|
282
283
|
}
|
package/lib/unified-config.ts
CHANGED
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
* which models the auto-selector may pick from.
|
|
21
21
|
* - `tiers`: list of `{ pattern, tier }` overrides for tier scoring.
|
|
22
22
|
* - `role_tiers`: `role → tier_name` map overriding the default expected
|
|
23
|
-
* tier (DEFAULT_ROLE_TIER in model-tiers).
|
|
23
|
+
* tier (DEFAULT_ROLE_TIER in model-tiers) for the four opsx agents.
|
|
24
|
+
* - `model_role_tiers`: `omp_role → tier_name | 'skip'` map controlling
|
|
25
|
+
* which selector tier each OMP model role (default/smol/slow/vision/plan/
|
|
26
|
+
* designer/commit/tiny/task/advisor + custom roles) follows after
|
|
27
|
+
* /pick-model; `skip` excludes the role from the session overlay.
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
import { existsSync, readFileSync } from 'fs';
|
|
@@ -60,6 +64,8 @@ export interface OpsxYamlShape {
|
|
|
60
64
|
model_allowlist?: string[];
|
|
61
65
|
tiers?: TierOverride[];
|
|
62
66
|
role_tiers?: Partial<Record<OpsxRole, TierName>>;
|
|
67
|
+
/** OMP model role → tier override; `skip` excludes the role from the overlay. Keys are arbitrary strings (custom roles allowed). */
|
|
68
|
+
model_role_tiers?: Record<string, string>;
|
|
63
69
|
plan_providers?: string[];
|
|
64
70
|
probe_enabled?: boolean;
|
|
65
71
|
probe_timeout_ms?: number;
|
|
@@ -90,6 +96,8 @@ export interface ResolvedOpsxConfig {
|
|
|
90
96
|
modelAllowlist: string[];
|
|
91
97
|
tierOverrides: TierOverride[];
|
|
92
98
|
roleTiers: Partial<Record<OpsxRole, TierName>>;
|
|
99
|
+
/** OMP model role → tier (or `'skip'`); drives the pick-model role overlay. */
|
|
100
|
+
ompRoleTiers: Record<string, TierName | 'skip'>;
|
|
93
101
|
/** Provider ids that have monthly plan subscriptions. */
|
|
94
102
|
planProviders: string[];
|
|
95
103
|
warnings: string[];
|
|
@@ -217,6 +225,36 @@ export function parseRoleTiers(
|
|
|
217
225
|
return out;
|
|
218
226
|
}
|
|
219
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Validate `model_role_tiers` entries. Keys are OMP model role names
|
|
230
|
+
* (built-in OR custom — never checked against the built-in set); values
|
|
231
|
+
* must be a tier name or the sentinel `'skip'`. Invalid entries are dropped
|
|
232
|
+
* with a warning.
|
|
233
|
+
*/
|
|
234
|
+
export function parseOmpRoleTiers(
|
|
235
|
+
raw: unknown,
|
|
236
|
+
warn: (msg: string) => void,
|
|
237
|
+
): Record<string, TierName | 'skip'> {
|
|
238
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
|
239
|
+
const out: Record<string, TierName | 'skip'> = {};
|
|
240
|
+
for (const [role, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
241
|
+
if (typeof v !== 'string') {
|
|
242
|
+
warn(`[omp-opsx-addon] model_role_tiers.${role}: expected tier name or 'skip', got ${typeof v}`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (v === 'skip') {
|
|
246
|
+
out[role] = 'skip';
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!TIER_NAMES.includes(v as TierName)) {
|
|
250
|
+
warn(`[omp-opsx-addon] model_role_tiers.${role}: expected tier name or 'skip' (got "${v}"; use one of ${TIER_NAMES.join('/')})`);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
out[role] = v as TierName;
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
220
258
|
/** Validate the model_allowlist (array of glob strings). */
|
|
221
259
|
export function parseModelAllowlist(
|
|
222
260
|
raw: unknown,
|
|
@@ -305,6 +343,7 @@ export function parseOpsxConfig(
|
|
|
305
343
|
modelAllowlist: parseModelAllowlist(obj.model_allowlist, localWarn),
|
|
306
344
|
tierOverrides: parseTierOverrides(obj.tiers, localWarn),
|
|
307
345
|
roleTiers: parseRoleTiers(obj.role_tiers, localWarn),
|
|
346
|
+
ompRoleTiers: parseOmpRoleTiers(obj.model_role_tiers, localWarn),
|
|
308
347
|
planProviders: sanitizePlanProviders(obj.plan_providers),
|
|
309
348
|
warnings,
|
|
310
349
|
probe_enabled: parseProbeBool(obj.probe_enabled, 'probe_enabled', true, localWarn),
|
|
@@ -357,6 +396,7 @@ function pickTopLevelKeys(raw: OpsxYamlShape): OpsxYamlShape {
|
|
|
357
396
|
model_allowlist: raw.model_allowlist,
|
|
358
397
|
tiers: raw.tiers,
|
|
359
398
|
role_tiers: raw.role_tiers,
|
|
399
|
+
model_role_tiers: raw.model_role_tiers,
|
|
360
400
|
plan_providers: raw.plan_providers,
|
|
361
401
|
probe_enabled: raw.probe_enabled,
|
|
362
402
|
probe_timeout_ms: raw.probe_timeout_ms,
|
|
@@ -448,6 +488,7 @@ export function readOpsxSettingsFromPaths(
|
|
|
448
488
|
merged.model_allowlist = projectTop.model_allowlist ?? globalTop.model_allowlist;
|
|
449
489
|
merged.tiers = projectTop.tiers ?? globalTop.tiers;
|
|
450
490
|
merged.role_tiers = projectTop.role_tiers ?? globalTop.role_tiers;
|
|
491
|
+
merged.model_role_tiers = projectTop.model_role_tiers ?? globalTop.model_role_tiers;
|
|
451
492
|
merged.plan_providers = projectTop.plan_providers ?? globalTop.plan_providers;
|
|
452
493
|
merged.probe_enabled = projectTop.probe_enabled ?? globalTop.probe_enabled;
|
|
453
494
|
merged.probe_timeout_ms = projectTop.probe_timeout_ms ?? globalTop.probe_timeout_ms;
|