@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.
@@ -0,0 +1,784 @@
1
+ // ── opsx pipe core ───────────────────────────────────────────────────
2
+ // Cross-broker, project-scoped message bus. Every omp broker (a primary
3
+ // session process/window) working in the same project directory shares the
4
+ // runtime tree under `<project>/.omp/opsx/pipe/`:
5
+ //
6
+ // registry/<broker-id>.json presence: pid, startedAt, sessions[],
7
+ // roster[]; file mtime is the heartbeat.
8
+ // mbox/<broker-id>/<msg-id>.json one mailbox slot per broker; messages are
9
+ // landed via same-directory tmp + rename.
10
+ //
11
+ // The broker-id is the primary session id (`ctx.sessionManager.getSessionId()`).
12
+ // This module implements the CORE only: directory layout, presence heartbeat,
13
+ // roster mirroring, the file mbox protocol (with the single shared `consume()`
14
+ // primitive), four LLM tools, and background GC. The `deliver` field is
15
+ // defined, validated, and stored — NEVER injected (injection is the scope of
16
+ // opsx-pipe-push).
17
+ import { promises as fs } from 'fs';
18
+ import { join } from 'path';
19
+ import type { ExtensionAPI, ExtensionContext } from '@oh-my-pi/pi-coding-agent';
20
+ import { TASK_SUBAGENT_EVENT_CHANNEL, TASK_SUBAGENT_LIFECYCLE_CHANNEL } from '@oh-my-pi/pi-coding-agent/task';
21
+
22
+ // ── constants ────────────────────────────────────────────────────────
23
+
24
+ /** Heartbeat: rewrite own presence file every 10s (mtime = heartbeat). */
25
+ export const HEARTBEAT_MS = 10_000;
26
+ /** Online TTL: presence mtime older than 30s counts as offline. */
27
+ export const PRESENCE_TTL_MS = 30_000;
28
+ /** GC: messages and corpse registry files older than 24h are deleted. */
29
+ export const GC_MAX_AGE_MS = 24 * 60 * 60_000;
30
+ /** GC scan period. */
31
+ export const GC_INTERVAL_MS = 60 * 60_000;
32
+ /** Maximum `body` size, in bytes. */
33
+ export const BODY_MAX_BYTES = 32 * 1024;
34
+ /** Wait-tool polling granularity. */
35
+ const WAIT_POLL_MS = 200;
36
+ /** Conservative broker-id whitelist: primary session ids are path segments
37
+ * under registry/ and mbox/, so reject separators, '.', '..', etc. */
38
+ const BROKER_ID_RE = /^[A-Za-z0-9_-]+$/;
39
+
40
+ export type DeliverMode = 'steer' | 'followUp' | 'nextTurn';
41
+ export const DELIVER_MODES: readonly DeliverMode[] = ['steer', 'followUp', 'nextTurn'];
42
+
43
+ /** The primary session's agent name (harness registry id MAIN_AGENT_ID). */
44
+ const MAIN_AGENT_ID = 'Main';
45
+
46
+ // ── message structure ────────────────────────────────────────────────
47
+
48
+ export interface Deliver {
49
+ mode: DeliverMode;
50
+ triggerTurn: boolean;
51
+ }
52
+
53
+ export interface PipeMessage {
54
+ /** Short random msg-id (sender-generated); used for idempotent dedup. */
55
+ id: string;
56
+ from: { broker: string; session: string; agent: typeof MAIN_AGENT_ID };
57
+ to: { broker: string; session?: string };
58
+ /** Sender-decided delivery semantics; core only validates/stores this. */
59
+ deliver: Deliver;
60
+ type: string;
61
+ body: string;
62
+ replyTo?: string;
63
+ /** Unix epoch seconds. */
64
+ ts: number;
65
+ }
66
+
67
+ export interface PresenceFile {
68
+ pid: number;
69
+ startedAt: number;
70
+ sessions: string[];
71
+ roster: string[];
72
+ }
73
+
74
+ export interface BrokerView {
75
+ broker: string;
76
+ pid: number;
77
+ startedAt: number;
78
+ sessions: string[];
79
+ roster: string[];
80
+ }
81
+
82
+ // ── deliver / message validation (pure, unit-tested) ─────────────────
83
+
84
+ /**
85
+ * Normalize a user-supplied `deliver` field: defaults are
86
+ * `{ mode: 'followUp', triggerTurn: true }`; an invalid `mode`/`triggerTurn`
87
+ * is rejected. core never acts on the field — pipe-push owns the
88
+ * mode → injection mapping.
89
+ */
90
+ export function normalizeDeliver(input: unknown): Deliver {
91
+ if (input === undefined || input === null) {
92
+ return { mode: 'followUp', triggerTurn: true };
93
+ }
94
+ if (typeof input !== 'object') {
95
+ throw new Error('deliver must be an object { mode, triggerTurn }');
96
+ }
97
+ const rec = input as Record<string, unknown>;
98
+ const mode = rec.mode === undefined ? 'followUp' : rec.mode;
99
+ if (typeof mode !== 'string' || !(DELIVER_MODES as readonly string[]).includes(mode)) {
100
+ throw new Error(`deliver.mode must be one of ${DELIVER_MODES.join('/')}, got: ${String(mode)}`);
101
+ }
102
+ const triggerTurn = rec.triggerTurn === undefined ? true : rec.triggerTurn;
103
+ if (typeof triggerTurn !== 'boolean') {
104
+ throw new Error('deliver.triggerTurn must be a boolean');
105
+ }
106
+ return { mode: mode as DeliverMode, triggerTurn };
107
+ }
108
+
109
+ function assertBody(body: unknown): asserts body is string {
110
+ if (typeof body !== 'string') {
111
+ throw new Error('body must be a string');
112
+ }
113
+ const bytes = Buffer.byteLength(body, 'utf-8');
114
+ if (bytes > BODY_MAX_BYTES) {
115
+ throw new Error(
116
+ `body exceeds ${BODY_MAX_BYTES} bytes (got ${bytes}); route large payloads via local:// references`,
117
+ );
118
+ }
119
+ }
120
+
121
+ function assertBrokerId(id: unknown, field: string): asserts id is string {
122
+ if (typeof id !== 'string' || !BROKER_ID_RE.test(id)) {
123
+ throw new Error(`${field} must be a non-empty broker id matching ${BROKER_ID_RE}`);
124
+ }
125
+ }
126
+
127
+ function mintMsgId(): string {
128
+ // Short random, filename-safe.
129
+ const raw =
130
+ globalThis.crypto?.randomUUID?.() ??
131
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
132
+ return raw.replace(/-/g, '').slice(0, 16);
133
+ }
134
+
135
+ // ── filesystem layout ────────────────────────────────────────────────
136
+
137
+ export function pipeRootDir(projectDir: string): string {
138
+ return join(projectDir, '.omp', 'opsx', 'pipe');
139
+ }
140
+
141
+ function registryDir(root: string): string {
142
+ return join(root, 'registry');
143
+ }
144
+
145
+ function mboxDir(root: string): string {
146
+ return join(root, 'mbox');
147
+ }
148
+
149
+ function brokerMboxDir(root: string, brokerId: string): string {
150
+ return join(mboxDir(root), brokerId);
151
+ }
152
+
153
+ function registryFile(root: string, brokerId: string): string {
154
+ return join(registryDir(root), `${brokerId}.json`);
155
+ }
156
+
157
+ /** mkdir -p registry/ and mbox/; idempotent. */
158
+ export async function ensureLayouts(root: string): Promise<void> {
159
+ await fs.mkdir(registryDir(root), { recursive: true });
160
+ await fs.mkdir(mboxDir(root), { recursive: true });
161
+ }
162
+
163
+ // ── atomic writes (same-directory tmp + rename) ──────────────────────
164
+
165
+ let writeSeq = 0;
166
+
167
+ async function atomicWriteJson(file: string, data: unknown): Promise<void> {
168
+ const dir = join(file, '..');
169
+ await fs.mkdir(dir, { recursive: true });
170
+ const tmp = `${file}.tmp-${process.pid}-${writeSeq++}`;
171
+ await fs.writeFile(tmp, JSON.stringify(data), 'utf-8');
172
+ await fs.rename(tmp, file);
173
+ }
174
+
175
+ // ── presence ─────────────────────────────────────────────────────────
176
+
177
+ export async function writePresence(root: string, brokerId: string, presence: PresenceFile): Promise<void> {
178
+ await atomicWriteJson(registryFile(root, brokerId), presence);
179
+ }
180
+
181
+ /**
182
+ * List online brokers: every `registry/*.json` whose mtime is within
183
+ * PRESENCE_TTL_MS. Stale brokers are filtered out. Unreadable entries are
184
+ * skipped (a half-written file is never visible: writers land via rename).
185
+ */
186
+ export async function listBrokers(root: string, now: number = Date.now()): Promise<BrokerView[]> {
187
+ const dir = registryDir(root);
188
+ let names: string[];
189
+ try {
190
+ names = await fs.readdir(dir);
191
+ } catch {
192
+ return [];
193
+ }
194
+ const out: BrokerView[] = [];
195
+ for (const name of names) {
196
+ if (!name.endsWith('.json')) continue;
197
+ const file = join(dir, name);
198
+ try {
199
+ const st = await fs.stat(file);
200
+ if (now - st.mtimeMs > PRESENCE_TTL_MS) continue;
201
+ const parsed = JSON.parse(await fs.readFile(file, 'utf-8')) as Partial<PresenceFile>;
202
+ out.push({
203
+ broker: name.slice(0, -'.json'.length),
204
+ pid: typeof parsed.pid === 'number' ? parsed.pid : -1,
205
+ startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : 0,
206
+ sessions: Array.isArray(parsed.sessions)
207
+ ? parsed.sessions.filter((s): s is string => typeof s === 'string')
208
+ : [],
209
+ roster: Array.isArray(parsed.roster)
210
+ ? parsed.roster.filter((s): s is string => typeof s === 'string')
211
+ : [],
212
+ });
213
+ } catch {
214
+ // Raced with a rewrite/delete, or corrupt — skip.
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+
220
+ // ── mbox send / consume ──────────────────────────────────────────────
221
+
222
+ export interface SendInput {
223
+ fromBroker: string;
224
+ toBroker: string;
225
+ toSession?: string;
226
+ body: string;
227
+ /** Raw deliver input; normalized/defaulted by {@link normalizeDeliver}. */
228
+ deliver?: unknown;
229
+ type?: string;
230
+ replyTo?: string;
231
+ /** Injectable for tests; defaults to a fresh minted id. */
232
+ id?: string;
233
+ ts?: number;
234
+ }
235
+
236
+ /** Build, validate, and atomically land a message in the target mbox. */
237
+ export async function sendMessage(root: string, input: SendInput): Promise<PipeMessage> {
238
+ assertBrokerId(input.fromBroker, 'from.broker');
239
+ assertBrokerId(input.toBroker, 'to.broker');
240
+ assertBody(input.body);
241
+ if (input.toSession !== undefined && typeof input.toSession !== 'string') {
242
+ throw new Error('to.session must be a string when provided');
243
+ }
244
+ if (input.replyTo !== undefined && typeof input.replyTo !== 'string') {
245
+ throw new Error('replyTo must be a string when provided');
246
+ }
247
+ const type = input.type === undefined ? 'text' : input.type;
248
+ if (typeof type !== 'string' || !type) {
249
+ throw new Error('type must be a non-empty string');
250
+ }
251
+
252
+ const msg: PipeMessage = {
253
+ id: input.id ?? mintMsgId(),
254
+ from: { broker: input.fromBroker, session: input.fromBroker, agent: MAIN_AGENT_ID },
255
+ to: { broker: input.toBroker, ...(input.toSession ? { session: input.toSession } : {}) },
256
+ deliver: normalizeDeliver(input.deliver),
257
+ type,
258
+ body: input.body,
259
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
260
+ ts: input.ts ?? Math.floor(Date.now() / 1000),
261
+ };
262
+
263
+ const file = join(brokerMboxDir(root, input.toBroker), `${msg.id}.json`);
264
+ await atomicWriteJson(file, msg);
265
+ return msg;
266
+ }
267
+
268
+ // Process-internal idempotency: consumed msg-ids per (root, broker). Cross-
269
+ // process, the atomic rename guarantees a single consumer wins a given file;
270
+ // worst case a redelivered id surfaces once per process (at-least-once).
271
+ const consumedIds = new Map<string, Set<string>>();
272
+
273
+ function dedupKey(root: string, brokerId: string): string {
274
+ return `${root}::${brokerId}`;
275
+ }
276
+
277
+ /** Test-only: clear the in-process dedup memory. */
278
+ export function _resetConsumeDedupForTest(): void {
279
+ consumedIds.clear();
280
+ }
281
+
282
+ /**
283
+ * The single consumption primitive. recv/wait tools AND the opsx-pipe-push
284
+ * injector MUST call this — no parallel rename/read/delete logic:
285
+ *
286
+ * 1. list `<root>/mbox/<brokerId>/*.json` (writers' tmp files ignored)
287
+ * 2. atomically `rename` each message aside (`.consumed-<pid>-<seq>`);
288
+ * a failed rename means another consumer won the file — skip it
289
+ * 3. read + parse, then delete; malformed files are deleted to avoid a
290
+ * redelivery loop
291
+ * 4. in-process msg-id dedup: an already-consumed id is dropped
292
+ *
293
+ * Returns the messages consumed in this call (filename order).
294
+ */
295
+ export async function consume(root: string, brokerId: string): Promise<PipeMessage[]> {
296
+ assertBrokerId(brokerId, 'brokerId');
297
+ const dir = brokerMboxDir(root, brokerId);
298
+ let names: string[];
299
+ try {
300
+ names = await fs.readdir(dir);
301
+ } catch {
302
+ return []; // no mailbox yet
303
+ }
304
+ const key = dedupKey(root, brokerId);
305
+ let seen = consumedIds.get(key);
306
+ if (!seen) {
307
+ seen = new Set();
308
+ consumedIds.set(key, seen);
309
+ }
310
+ const out: PipeMessage[] = [];
311
+ for (const name of names) {
312
+ // Only settled message files; never touch writers' tmp files.
313
+ if (!name.endsWith('.json')) continue;
314
+ const src = join(dir, name);
315
+ const aside = join(dir, `${name}.consumed-${process.pid}-${writeSeq++}`);
316
+ try {
317
+ await fs.rename(src, aside);
318
+ } catch {
319
+ // ENOENT/race: another consumer (recv/wait or the pipe-push
320
+ // injector) claimed it first — rename atomicity is the
321
+ // single-winner gate.
322
+ continue;
323
+ }
324
+ let parsed: PipeMessage | null = null;
325
+ try {
326
+ parsed = JSON.parse(await fs.readFile(aside, 'utf-8')) as PipeMessage;
327
+ } catch {
328
+ parsed = null;
329
+ } finally {
330
+ // Read complete → delete (the delete in rename→read→delete).
331
+ await fs.rm(aside, { force: true }).catch(() => {});
332
+ }
333
+ if (!parsed || typeof parsed.id !== 'string' || typeof parsed.body !== 'string') {
334
+ continue; // corrupt — already removed
335
+ }
336
+ if (seen.has(parsed.id)) continue; // in-process idempotency
337
+ seen.add(parsed.id);
338
+ out.push(parsed);
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /**
344
+ * Block up to `timeoutMs` for messages, consuming as soon as any arrive.
345
+ * Returns immediately with whatever was consumed on the first non-empty poll;
346
+ * returns [] on timeout. Elapsed time is accumulated from the (injectable)
347
+ * sleep durations, which keeps tests deterministic under a fake scheduler.
348
+ */
349
+ export async function waitForMessages(
350
+ root: string,
351
+ brokerId: string,
352
+ timeoutMs: number,
353
+ opts?: { sleep?: (ms: number) => Promise<void>; signal?: AbortSignal },
354
+ ): Promise<PipeMessage[]> {
355
+ const sleep = opts?.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
356
+ let elapsed = 0;
357
+ for (;;) {
358
+ if (opts?.signal?.aborted) return [];
359
+ const msgs = await consume(root, brokerId);
360
+ if (msgs.length > 0) return msgs;
361
+ if (elapsed >= timeoutMs) return [];
362
+ const step = Math.min(WAIT_POLL_MS, timeoutMs - elapsed);
363
+ elapsed += step;
364
+ await sleep(step);
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Delete mbox messages older than GC_MAX_AGE_MS (by file mtime) and corpse
370
+ * registry files (broker absent far longer than the online TTL).
371
+ */
372
+ export async function runGc(root: string, now: number = Date.now()): Promise<void> {
373
+ // mbox: <root>/mbox/<broker-id>/<msg-id>.json
374
+ const mbox = mboxDir(root);
375
+ let brokerDirs: string[] = [];
376
+ try {
377
+ brokerDirs = await fs.readdir(mbox);
378
+ } catch {
379
+ brokerDirs = [];
380
+ }
381
+ for (const b of brokerDirs) {
382
+ const dir = join(mbox, b);
383
+ let files: string[];
384
+ try {
385
+ const st = await fs.stat(dir);
386
+ if (!st.isDirectory()) continue;
387
+ files = await fs.readdir(dir);
388
+ } catch {
389
+ continue;
390
+ }
391
+ for (const f of files) {
392
+ const file = join(dir, f);
393
+ try {
394
+ const st = await fs.stat(file);
395
+ if (st.isFile() && now - st.mtimeMs > GC_MAX_AGE_MS) {
396
+ await fs.rm(file, { force: true });
397
+ }
398
+ } catch {
399
+ // raced — ignore
400
+ }
401
+ }
402
+ }
403
+ // registry corpses
404
+ const reg = registryDir(root);
405
+ let regFiles: string[] = [];
406
+ try {
407
+ regFiles = await fs.readdir(reg);
408
+ } catch {
409
+ regFiles = [];
410
+ }
411
+ for (const f of regFiles) {
412
+ if (!f.endsWith('.json')) continue;
413
+ const file = join(reg, f);
414
+ try {
415
+ const st = await fs.stat(file);
416
+ if (now - st.mtimeMs > GC_MAX_AGE_MS) {
417
+ await fs.rm(file, { force: true });
418
+ }
419
+ } catch {
420
+ // raced — ignore
421
+ }
422
+ }
423
+ }
424
+
425
+ // ── eventBus access (same hack as the index.ts file header) ──────────
426
+ // Plugins cannot subscribe to eventBus channels via the official
427
+ // ExtensionAPI; we reach through internal properties. The channel string is
428
+ // hardcoded from harness src/task/types.ts:59.
429
+
430
+ function getEventBusFromCtx(ctx: ExtensionContext): unknown {
431
+ const sessionManager = ctx.sessionManager as unknown as Record<string, unknown> | undefined;
432
+ if (!sessionManager) return undefined;
433
+ for (const key of ['session', '_session', '__session', 'eventBus', '_eventBus']) {
434
+ const val = sessionManager[key];
435
+ if (val && typeof val === 'object') {
436
+ const obj = val as Record<string, unknown>;
437
+ if (typeof obj.eventBus !== 'undefined') return obj.eventBus;
438
+ if (key === 'eventBus' || key === '_eventBus') return val;
439
+ }
440
+ }
441
+ return undefined;
442
+ }
443
+
444
+ function subscribeEventBus(eventBus: unknown, channel: string, handler: (data: unknown) => void): () => void {
445
+ const bus = eventBus as Record<string, unknown>;
446
+ const onMethod = bus.on as ((ch: string, h: (data: unknown) => void) => () => void) | undefined;
447
+ if (typeof onMethod === 'function') return onMethod.call(bus, channel, handler);
448
+ return () => {};
449
+ }
450
+
451
+ // ── per-broker runtime state ─────────────────────────────────────────
452
+
453
+ interface BrokerState {
454
+ brokerId: string;
455
+ root: string;
456
+ startedAt: number;
457
+ /** Active agent ids observed on the subagent event channel; pruned when
458
+ * the lifecycle channel reports a terminal status. */
459
+ agents: Set<string>;
460
+ /** eventBus unsubscribers (best-effort cleanup on shutdown). */
461
+ unsubscribe?: () => void;
462
+ /** Managed timer handles (ctx.setInterval), cleared via ctx.clearTimer. */
463
+ heartbeatTimer?: ReturnType<ExtensionContext['setInterval']>;
464
+ gcTimer?: ReturnType<ExtensionContext['setInterval']>;
465
+ }
466
+
467
+ const states = new Map<string, BrokerState>();
468
+
469
+ function stateFor(ctx: ExtensionContext): BrokerState {
470
+ const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
471
+ const existing = states.get(brokerId);
472
+ if (existing) return existing;
473
+ const state: BrokerState = {
474
+ brokerId,
475
+ root: pipeRootDir(ctx.cwd),
476
+ startedAt: Date.now(),
477
+ agents: new Set(),
478
+ };
479
+ states.set(brokerId, state);
480
+ return state;
481
+ }
482
+
483
+ async function heartbeat(state: BrokerState): Promise<void> {
484
+ // sessions[] and roster[] are same-source: ids collected from the
485
+ // TASK_SUBAGENT_EVENT_CHANNEL subscription, always unioned with the
486
+ // primary session itself. roster names the active agents (Main first);
487
+ // sessions carries the primary session UUID (the broker-id) plus the
488
+ // same observed agent ids — those are the hub-addressable targets
489
+ // (to.session). The primary session is the only session that loads
490
+ // extensions, which guarantees both lists are never empty.
491
+ const roster = Array.from(new Set([MAIN_AGENT_ID, ...state.agents])).sort();
492
+ const sessions = Array.from(new Set([state.brokerId, ...state.agents])).sort();
493
+ await writePresence(state.root, state.brokerId, {
494
+ pid: process.pid,
495
+ startedAt: state.startedAt,
496
+ sessions,
497
+ roster,
498
+ });
499
+ }
500
+
501
+ function trackSubagentFrame(state: BrokerState, payload: unknown): void {
502
+ try {
503
+ if (!payload || typeof payload !== 'object') return;
504
+ const p = payload as { id?: unknown };
505
+ if (typeof p.id === 'string' && p.id) state.agents.add(p.id);
506
+ } catch {
507
+ /* best-effort */
508
+ }
509
+ }
510
+
511
+ /** Mirror roster membership from the lifecycle channel: 'started' adds as a
512
+ * fallback for agents that never emit an event frame; terminal statuses
513
+ * ('completed' | 'failed' | 'aborted') prune the id so opsx_pipe_list
514
+ * never advertises a dead session. The primary session is re-unioned at
515
+ * every heartbeat and can never be cropped. */
516
+ function trackSubagentLifecycle(state: BrokerState, payload: unknown): void {
517
+ try {
518
+ if (!payload || typeof payload !== 'object') return;
519
+ const p = payload as { id?: unknown; status?: unknown };
520
+ if (typeof p.id !== 'string' || !p.id) return;
521
+ if (p.status === 'completed' || p.status === 'failed' || p.status === 'aborted') {
522
+ state.agents.delete(p.id);
523
+ } else if (p.status === 'started') {
524
+ state.agents.add(p.id);
525
+ }
526
+ } catch {
527
+ /* best-effort */
528
+ }
529
+ }
530
+
531
+ /** Initialize (idempotently) pipe core for the primary session of `ctx`. */
532
+ async function ensureBrokerStarted(ctx: ExtensionContext): Promise<BrokerState> {
533
+ const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
534
+ // P2-2 guard: refuse to bootstrap against an id that would escape the
535
+ // registry directory (e.g. empty, '/', '..') — otherwise we'd write
536
+ // registry/.json or clobber an unrelated path.
537
+ if (!BROKER_ID_RE.test(brokerId)) {
538
+ throw new Error(`[opsx-pipe] invalid broker id, refusing to start: ${JSON.stringify(brokerId)}`);
539
+ }
540
+ const state = stateFor(ctx);
541
+ if (state.heartbeatTimer) return state;
542
+
543
+ await ensureLayouts(state.root);
544
+
545
+ // Roster/sessions mirror: event channel adds active ids; the lifecycle
546
+ // channel prunes them on completion/failure/abort. Same eventBus hack
547
+ // as index.ts's subagent usage subscription; channel constants are
548
+ // imported from the harness, not hardcoded.
549
+ const eventBus = getEventBusFromCtx(ctx);
550
+ if (eventBus) {
551
+ const unsubEvent = subscribeEventBus(eventBus, TASK_SUBAGENT_EVENT_CHANNEL, (payload) =>
552
+ trackSubagentFrame(state, payload),
553
+ );
554
+ const unsubLifecycle = subscribeEventBus(eventBus, TASK_SUBAGENT_LIFECYCLE_CHANNEL, (payload) =>
555
+ trackSubagentLifecycle(state, payload),
556
+ );
557
+ state.unsubscribe = () => {
558
+ try {
559
+ unsubEvent?.();
560
+ } catch {
561
+ /* best-effort */
562
+ }
563
+ try {
564
+ unsubLifecycle?.();
565
+ } catch {
566
+ /* best-effort */
567
+ }
568
+ };
569
+ }
570
+ await heartbeat(state);
571
+
572
+ // Managed timers are mandatory — a raw setInterval throw would surface
573
+ // as an uncaughtException and tear the whole session down (extensions.md
574
+ // "Background work"). Callback bodies additionally guard their errors.
575
+ const guarded = (fn: () => Promise<void>): (() => Promise<void>) => () => {
576
+ return fn().catch(() => {
577
+ /* heartbeat/GC failures must not kill the timer */
578
+ });
579
+ };
580
+ state.heartbeatTimer = ctx.setInterval(guarded(() => heartbeat(state)), HEARTBEAT_MS);
581
+ state.gcTimer = ctx.setInterval(guarded(() => runGc(state.root)), GC_INTERVAL_MS);
582
+
583
+ return state;
584
+ }
585
+
586
+ async function stopBroker(ctx: ExtensionContext): Promise<void> {
587
+ const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
588
+ const state = brokerId ? states.get(brokerId) : undefined;
589
+ if (!state) return;
590
+ states.delete(brokerId);
591
+ try {
592
+ state.unsubscribe?.();
593
+ } catch {
594
+ /* best-effort */
595
+ }
596
+ // Managed timers are also reaped automatically on session_shutdown, but
597
+ // clear explicitly so a shutdown followed by a restart in-process cannot
598
+ // double-tick.
599
+ if (state.heartbeatTimer) ctx.clearTimer(state.heartbeatTimer);
600
+ if (state.gcTimer) ctx.clearTimer(state.gcTimer);
601
+ // Drop our presence so list stops advertising this broker immediately;
602
+ // TTL/GC would reclaim the corpse anyway.
603
+ await fs.rm(registryFile(state.root, brokerId), { force: true }).catch(() => {});
604
+ }
605
+
606
+ // ── tool registration ────────────────────────────────────────────────
607
+
608
+ function textResult(text: string, details: unknown) {
609
+ return {
610
+ content: [{ type: 'text' as const, text }],
611
+ details,
612
+ };
613
+ }
614
+
615
+ // Tool parameter shapes (zod-validated at the harness boundary). pi-ai's
616
+ // Static<> only resolves ArkType schemas, so execute sees unknown params for
617
+ // a pi.zod schema; we re-state the validated shape here.
618
+ interface SendToolParams {
619
+ to: { broker: string; session?: string };
620
+ deliver?: { mode?: DeliverMode; triggerTurn?: boolean };
621
+ type?: string;
622
+ body: string;
623
+ replyTo?: string;
624
+ }
625
+
626
+ interface WaitToolParams {
627
+ timeout: number;
628
+ }
629
+
630
+ export function registerPipeCore(pi: ExtensionAPI): void {
631
+ const z = pi.zod;
632
+
633
+ // ── opsx_pipe_list ──
634
+ pi.registerTool({
635
+ name: 'opsx_pipe_list',
636
+ label: 'Pipe: list brokers',
637
+ description:
638
+ 'List online brokers (omp sessions open in this project) and their rosters, cross-broker. Brokers whose heartbeat is older than 30s are filtered out as offline.',
639
+ parameters: z.object({}),
640
+ approval: 'read',
641
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
642
+ const state = await ensureBrokerStarted(ctx);
643
+ const brokers = await listBrokers(state.root);
644
+ return textResult(
645
+ [
646
+ `Online brokers: ${brokers.length}`,
647
+ ...brokers.map(
648
+ (b) =>
649
+ `- ${b.broker}${b.broker === state.brokerId ? ' (self)' : ''} roster: [${b.roster.join(', ')}] sessions: [${b.sessions.join(', ')}]`,
650
+ ),
651
+ ].join('\n'),
652
+ { brokers },
653
+ );
654
+ },
655
+ });
656
+
657
+ // ── opsx_pipe_send ──
658
+ pi.registerTool({
659
+ name: 'opsx_pipe_send',
660
+ label: 'Pipe: send message',
661
+ description:
662
+ 'Fire-and-forget a text message to another broker\'s mbox (same semantics as hub send). Validates the deliver field { mode: steer|followUp|nextTurn, triggerTurn } (defaults followUp/true); core stores but never injects it. body is capped at 32KB.',
663
+ parameters: z.object({
664
+ to: z.object({
665
+ broker: z.string().describe('Target broker id (a primary session id; see opsx_pipe_list)'),
666
+ session: z.string().optional().describe('Optional target agent/session for hub relay'),
667
+ }),
668
+ deliver: z
669
+ .object({
670
+ mode: z.enum(['steer', 'followUp', 'nextTurn']),
671
+ triggerTurn: z.boolean(),
672
+ })
673
+ .partial()
674
+ .optional()
675
+ .describe('Delivery semantics; defaults { mode: followUp, triggerTurn: true }'),
676
+ type: z.string().optional().describe('Message type; defaults to "text"'),
677
+ body: z.string().describe('Message body, max 32KB'),
678
+ replyTo: z.string().optional().describe('msg-id this replies to'),
679
+ }),
680
+ approval: 'write',
681
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
682
+ // pi-ai's Static<> resolves ArkType schemas only; the pi.zod
683
+ // omptype shim yields unknown here (same cast the harness's own
684
+ // with-deps example uses), so the validated shape is declared
685
+ // explicitly. normalizeDeliver re-validates deliver at runtime.
686
+ const p = params as SendToolParams;
687
+ const state = await ensureBrokerStarted(ctx);
688
+ const msg = await sendMessage(state.root, {
689
+ fromBroker: state.brokerId,
690
+ toBroker: p.to.broker,
691
+ toSession: p.to.session,
692
+ deliver: p.deliver,
693
+ type: p.type,
694
+ body: p.body,
695
+ replyTo: p.replyTo,
696
+ });
697
+ return textResult(`Sent message ${msg.id} to broker ${msg.to.broker}`, {
698
+ id: msg.id,
699
+ to: msg.to,
700
+ });
701
+ },
702
+ });
703
+
704
+ // ── opsx_pipe_recv ──
705
+ pi.registerTool({
706
+ name: 'opsx_pipe_recv',
707
+ label: 'Pipe: receive messages',
708
+ description:
709
+ 'Non-blocking: consume all unconsumed messages from this broker\'s mbox (atomic rename→read→delete). Returns a tool result only — no conversation entry is created. An empty mbox returns an empty list.',
710
+ parameters: z.object({}),
711
+ approval: 'read',
712
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
713
+ const state = await ensureBrokerStarted(ctx);
714
+ const messages = await consume(state.root, state.brokerId);
715
+ return textResult(
716
+ messages.length === 0
717
+ ? 'No messages in mbox.'
718
+ : `Consumed ${messages.length} message(s):\n${JSON.stringify(messages, null, 2)}`,
719
+ { messages },
720
+ );
721
+ },
722
+ });
723
+
724
+ // ── opsx_pipe_wait ──
725
+ pi.registerTool({
726
+ name: 'opsx_pipe_wait',
727
+ label: 'Pipe: wait for message',
728
+ description:
729
+ 'Block up to timeout (ms) waiting for messages in this broker\'s mbox; consumes and returns them as soon as any arrive. Returns an empty list on timeout.',
730
+ parameters: z.object({
731
+ timeout: z.number().int().positive().describe('Max wait time in milliseconds'),
732
+ }),
733
+ approval: 'read',
734
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
735
+ const p = params as WaitToolParams;
736
+ const state = await ensureBrokerStarted(ctx);
737
+ // Managed setTimeout is cleared on session shutdown; race the
738
+ // sleep against the abort signal so cancellation still resolves.
739
+ const sleep = (ms: number): Promise<void> =>
740
+ new Promise((resolve) => {
741
+ if (signal?.aborted) return resolve(undefined);
742
+ const timer = ctx.setTimeout(() => resolve(undefined), ms);
743
+ signal?.addEventListener(
744
+ 'abort',
745
+ () => {
746
+ ctx.clearTimer(timer);
747
+ resolve(undefined);
748
+ },
749
+ { once: true },
750
+ );
751
+ });
752
+ const messages = await waitForMessages(state.root, state.brokerId, p.timeout, {
753
+ sleep,
754
+ signal,
755
+ });
756
+ return textResult(
757
+ messages.length === 0
758
+ ? `Timed out after ${p.timeout}ms with no messages.`
759
+ : `Consumed ${messages.length} message(s):\n${JSON.stringify(messages, null, 2)}`,
760
+ { messages, timedOut: messages.length === 0 },
761
+ );
762
+ },
763
+ });
764
+
765
+ // registerTool returns void and silently skips name conflicts — log
766
+ // registration so collision with another extension is diagnosable.
767
+ pi.logger?.debug?.('[opsx-pipe] registered 4 tools');
768
+
769
+ // Heartbeat/GC lifecycle: start on session_start, tear down on shutdown.
770
+ pi.on('session_start', async (_event, ctx) => {
771
+ try {
772
+ await ensureBrokerStarted(ctx);
773
+ } catch {
774
+ /* pipe core is best-effort; never break session start */
775
+ }
776
+ });
777
+ pi.on('session_shutdown', async (_event, ctx) => {
778
+ try {
779
+ await stopBroker(ctx);
780
+ } catch {
781
+ /* best-effort */
782
+ }
783
+ });
784
+ }