@genee/omp-opsx-addon 0.8.0 → 0.10.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/lib/pipe-core.ts CHANGED
@@ -4,21 +4,30 @@
4
4
  // runtime tree under `<project>/.omp/opsx/pipe/`:
5
5
  //
6
6
  // registry/<broker-id>.json presence: pid, startedAt, sessions[],
7
- // roster[]; file mtime is the heartbeat.
7
+ // roster[], work{tasks[],changedFiles[],
8
+ // gitAvailable}; file mtime is the heartbeat.
8
9
  // mbox/<broker-id>/<msg-id>.json one mailbox slot per broker; messages are
9
10
  // landed via same-directory tmp + rename.
10
11
  //
11
12
  // 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. `to.broker: "all"` is reserved
15
- // for online-peer fanout (never a literal mbox/all/ slot). The `deliver` field
16
- // is defined, validated, and stored — NEVER injected (injection is the scope of
17
- // opsx-pipe-push).
13
+ // This module implements the CORE only: directory layout, presence heartbeat
14
+ // (with a per-broker work summary peers use to pick a recipient: active
15
+ // subagent assignments + a cached git working-tree listing), roster mirroring,
16
+ // the file mbox protocol (with the single shared `consume()` primitive), four
17
+ // LLM tools, and background GC. The pipe is strictly 1:1: there is NO
18
+ // broadcast/fanout — senders MUST choose one broker from opsx_pipe_list, and
19
+ // to.broker "all" is rejected (never a literal mbox/all/ slot). The `deliver`
20
+ // field is defined, validated, and stored — NEVER injected (injection is the
21
+ // scope of opsx-pipe-push).
18
22
  import { promises as fs } from 'fs';
23
+ import { spawn } from 'child_process';
19
24
  import { join } from 'path';
20
25
  import type { ExtensionAPI, ExtensionContext } from '@oh-my-pi/pi-coding-agent';
21
- import { TASK_SUBAGENT_EVENT_CHANNEL, TASK_SUBAGENT_LIFECYCLE_CHANNEL } from '@oh-my-pi/pi-coding-agent/task';
26
+ import {
27
+ TASK_SUBAGENT_EVENT_CHANNEL,
28
+ TASK_SUBAGENT_LIFECYCLE_CHANNEL,
29
+ TASK_SUBAGENT_PROGRESS_CHANNEL,
30
+ } from '@oh-my-pi/pi-coding-agent/task';
22
31
 
23
32
  // ── constants ────────────────────────────────────────────────────────
24
33
 
@@ -65,11 +74,28 @@ export interface PipeMessage {
65
74
  ts: number;
66
75
  }
67
76
 
77
+ /** What a broker is currently doing, mirrored into every heartbeat so peers
78
+ * can pick the right 1:1 recipient from opsx_pipe_list. */
79
+ export interface BrokerWork {
80
+ /** One-line summaries of currently active subagent assignments. */
81
+ tasks: string[];
82
+ /** Working-tree changes at the last probe (`git status --porcelain`). */
83
+ changedFiles: string[];
84
+ /** False when git status cannot be produced (not a repo / git missing):
85
+ * distinguishes a clean tree from "unknown". */
86
+ gitAvailable: boolean;
87
+ }
88
+
89
+ const EMPTY_WORK: BrokerWork = { tasks: [], changedFiles: [], gitAvailable: false };
90
+
68
91
  export interface PresenceFile {
69
92
  pid: number;
70
93
  startedAt: number;
71
94
  sessions: string[];
72
95
  roster: string[];
96
+ /** Absent in presence files written by older builds; filled with empty
97
+ * defaults when read. */
98
+ work?: BrokerWork;
73
99
  }
74
100
 
75
101
  export interface BrokerView {
@@ -78,6 +104,7 @@ export interface BrokerView {
78
104
  startedAt: number;
79
105
  sessions: string[];
80
106
  roster: string[];
107
+ work: BrokerWork;
81
108
  }
82
109
 
83
110
  // ── deliver / message validation (pure, unit-tested) ─────────────────
@@ -125,6 +152,17 @@ function assertBrokerId(id: unknown, field: string): asserts id is string {
125
152
  }
126
153
  }
127
154
 
155
+ /** Max characters of a subagent assignment mirrored into a presence task
156
+ * line; the point is peer triage, not the full brief. */
157
+ export const TASK_LINE_MAX = 200;
158
+
159
+ /** Collapse an assignment/description into the single-line, length-capped
160
+ * form published in presence files. */
161
+ export function summarizeTask(text: string): string {
162
+ const line = text.replace(/\s+/g, ' ').trim();
163
+ return line.length > TASK_LINE_MAX ? `${line.slice(0, TASK_LINE_MAX - 1)}…` : line;
164
+ }
165
+
128
166
  function mintMsgId(): string {
129
167
  // Short random, filename-safe.
130
168
  const raw =
@@ -175,6 +213,32 @@ async function atomicWriteJson(file: string, data: unknown): Promise<void> {
175
213
 
176
214
  // ── presence ─────────────────────────────────────────────────────────
177
215
 
216
+ /** Max task lines / changed files mirrored into a presence file. */
217
+ export const WORK_TASK_MAX = 8;
218
+ export const WORK_FILE_MAX = 40;
219
+ /** Refresh cadence for the git working-tree probe (per broker). */
220
+ export const WORK_REFRESH_MS = 30_000;
221
+ /** Changed-file lines actually rendered in the list tool's text output
222
+ * (structured details still carry up to WORK_FILE_MAX). */
223
+ const CHANGED_FILE_RENDER_MAX = 12;
224
+
225
+ /** Tolerantly read a `work` block off disk (older writers omit it; corrupt
226
+ * or oversized arrays are clamped rather than rejected — presence must
227
+ * never disappear because a summary was malformed). */
228
+ export function normalizeWork(input: unknown): BrokerWork {
229
+ if (!input || typeof input !== 'object') return { ...EMPTY_WORK };
230
+ const rec = input as Record<string, unknown>;
231
+ const strs = (v: unknown, cap: number): string[] =>
232
+ Array.isArray(v)
233
+ ? v.filter((x): x is string => typeof x === 'string').slice(0, cap)
234
+ : [];
235
+ return {
236
+ tasks: strs(rec.tasks, WORK_TASK_MAX),
237
+ changedFiles: strs(rec.changedFiles, WORK_FILE_MAX),
238
+ gitAvailable: rec.gitAvailable === true,
239
+ };
240
+ }
241
+
178
242
  export async function writePresence(root: string, brokerId: string, presence: PresenceFile): Promise<void> {
179
243
  await atomicWriteJson(registryFile(root, brokerId), presence);
180
244
  }
@@ -200,6 +264,7 @@ export async function listBrokers(root: string, now: number = Date.now()): Promi
200
264
  const st = await fs.stat(file);
201
265
  if (now - st.mtimeMs > PRESENCE_TTL_MS) continue;
202
266
  const parsed = JSON.parse(await fs.readFile(file, 'utf-8')) as Partial<PresenceFile>;
267
+ const work = normalizeWork(parsed.work);
203
268
  out.push({
204
269
  broker: name.slice(0, -'.json'.length),
205
270
  pid: typeof parsed.pid === 'number' ? parsed.pid : -1,
@@ -210,6 +275,7 @@ export async function listBrokers(root: string, now: number = Date.now()): Promi
210
275
  roster: Array.isArray(parsed.roster)
211
276
  ? parsed.roster.filter((s): s is string => typeof s === 'string')
212
277
  : [],
278
+ work,
213
279
  });
214
280
  } catch {
215
281
  // Raced with a rewrite/delete, or corrupt — skip.
@@ -238,12 +304,11 @@ export interface SendInput {
238
304
  export async function sendMessage(root: string, input: SendInput): Promise<PipeMessage> {
239
305
  assertBrokerId(input.fromBroker, 'from.broker');
240
306
  assertBrokerId(input.toBroker, 'to.broker');
241
- // "all" is reserved for broadcast fanout (see {@link broadcastMessage});
242
- // never land a literal mbox/all/ dead-letter slot.
307
+ // The pipe is strictly 1:1: "all" is not a broker and there is no
308
+ // fanout path that would drain an mbox/all/ slot, so refuse rather
309
+ // than land a dead letter.
243
310
  if (input.toBroker === 'all') {
244
- throw new Error(
245
- 'to.broker "all" is reserved for broadcast fanout; refuse to write mbox/all/',
246
- );
311
+ throw new Error('to.broker "all" is not a valid target: the pipe is 1:1 only, there is no broadcast');
247
312
  }
248
313
  assertBody(input.body);
249
314
  if (input.toSession !== undefined && typeof input.toSession !== 'string') {
@@ -273,38 +338,6 @@ export async function sendMessage(root: string, input: SendInput): Promise<PipeM
273
338
  return msg;
274
339
  }
275
340
 
276
- /**
277
- * Fan out one outbound body to every currently-online broker except the
278
- * sender. Online = presence mtime within {@link PRESENCE_TTL_MS} (same filter
279
- * as opsx_pipe_list). Each recipient gets its own msg-id / mbox file; nothing
280
- * is written to a literal `mbox/all/` slot. `to.session` is rejected (a
281
- * broadcast has no single last-mile session).
282
- */
283
- export async function broadcastMessage(
284
- root: string,
285
- input: Omit<SendInput, 'toBroker' | 'toSession' | 'id'>,
286
- ): Promise<PipeMessage[]> {
287
- assertBrokerId(input.fromBroker, 'from.broker');
288
- assertBody(input.body);
289
- const online = await listBrokers(root);
290
- const targets = online.map((b) => b.broker).filter((id) => id !== input.fromBroker);
291
- const out: PipeMessage[] = [];
292
- for (const toBroker of targets) {
293
- out.push(
294
- await sendMessage(root, {
295
- fromBroker: input.fromBroker,
296
- toBroker,
297
- deliver: input.deliver,
298
- type: input.type,
299
- body: input.body,
300
- replyTo: input.replyTo,
301
- ts: input.ts,
302
- }),
303
- );
304
- }
305
- return out;
306
- }
307
-
308
341
  // Process-internal idempotency: consumed msg-ids per (root, broker). Cross-
309
342
  // process, the atomic rename guarantees a single consumer wins a given file;
310
343
  // worst case a redelivered id surfaces once per process (at-least-once).
@@ -493,10 +526,21 @@ function subscribeEventBus(eventBus: unknown, channel: string, handler: (data: u
493
526
  interface BrokerState {
494
527
  brokerId: string;
495
528
  root: string;
529
+ cwd: string;
496
530
  startedAt: number;
497
531
  /** Active agent ids observed on the subagent event channel; pruned when
498
532
  * the lifecycle channel reports a terminal status. */
499
533
  agents: Set<string>;
534
+ /** agent id → one-line summary of what that subagent is doing, fed by
535
+ * the progress channel's `assignment` (fallback: lifecycle
536
+ * `description`). Cleared together with {@link agents}. */
537
+ taskById: Map<string, string>;
538
+ /** Latest git working-tree probe result, refreshed off the heartbeat
539
+ * path every WORK_REFRESH_MS. */
540
+ changedFiles: string[];
541
+ gitAvailable: boolean;
542
+ gitProbing: boolean;
543
+ gitProbedAt: number;
500
544
  /** eventBus unsubscribers (best-effort cleanup on shutdown). */
501
545
  unsubscribe?: () => void;
502
546
  /** Managed timer handles (ctx.setInterval), cleared via ctx.clearTimer. */
@@ -513,13 +557,118 @@ function stateFor(ctx: ExtensionContext): BrokerState {
513
557
  const state: BrokerState = {
514
558
  brokerId,
515
559
  root: pipeRootDir(ctx.cwd),
560
+ cwd: ctx.cwd,
516
561
  startedAt: Date.now(),
517
562
  agents: new Set(),
563
+ taskById: new Map(),
564
+ changedFiles: [],
565
+ gitAvailable: false,
566
+ gitProbing: false,
567
+ gitProbedAt: 0,
518
568
  };
519
569
  states.set(brokerId, state);
520
570
  return state;
521
571
  }
522
572
 
573
+ /** Result of a working-tree probe: a verified-clean repo reports
574
+ * gitAvailable=true with an empty list; a non-repo / missing git reports
575
+ * gitAvailable=false so the list renderer can say "unknown" rather than
576
+ * "clean". */
577
+ export interface GitStatusResult {
578
+ gitAvailable: boolean;
579
+ changedFiles: string[];
580
+ }
581
+
582
+ export type GitStatusProbe = (cwd: string) => Promise<GitStatusResult>;
583
+
584
+ /** Default probe: `git status --porcelain=v1 -z` in the broker's cwd. */
585
+ const defaultGitStatusProbe: GitStatusProbe = (cwd) => {
586
+ const { promise, resolve } = Promise.withResolvers<GitStatusResult>();
587
+ const unavailable: GitStatusResult = { gitAvailable: false, changedFiles: [] };
588
+ let child;
589
+ try {
590
+ child = spawn(
591
+ 'git',
592
+ ['-C', cwd, 'status', '--porcelain=v1', '--untracked-files=all', '-z'],
593
+ { stdio: ['ignore', 'pipe', 'ignore'] },
594
+ );
595
+ } catch {
596
+ resolve(unavailable);
597
+ return promise;
598
+ }
599
+ const chunks: Buffer[] = [];
600
+ child.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk));
601
+ // A wedged git (locked index, slow NFS) must never suppress all future
602
+ // probes or hold the broker process open.
603
+ const timer = setTimeout(() => {
604
+ child.kill();
605
+ resolve(unavailable);
606
+ }, 10_000);
607
+ timer.unref?.();
608
+ child.once('error', () => {
609
+ clearTimeout(timer);
610
+ resolve(unavailable);
611
+ });
612
+ child.unref();
613
+ child.on('close', (code) => {
614
+ clearTimeout(timer);
615
+ if (code !== 0) {
616
+ resolve(unavailable);
617
+ return;
618
+ }
619
+ // porcelain -z: entries "XY path\0"; rename/copy carry a second
620
+ // NUL-separated path ("XY old\0new\0"), so a plain split/slice
621
+ // would misread the bare new path as status-prefixed.
622
+ const tokens = Buffer.concat(chunks).toString('utf-8').split('\0');
623
+ const files: string[] = [];
624
+ for (let i = 0; i < tokens.length; i++) {
625
+ const entry = tokens[i];
626
+ if (entry.length < 4) continue; // 2 status cols + space + path
627
+ files.push(entry.slice(3));
628
+ if (entry[0] === 'R' || entry[0] === 'C') {
629
+ const newPath = tokens[++i];
630
+ if (newPath) files.push(newPath);
631
+ }
632
+ }
633
+ resolve({ gitAvailable: true, changedFiles: Array.from(new Set(files)).slice(0, WORK_FILE_MAX) });
634
+ });
635
+ return promise;
636
+ };
637
+
638
+ let gitStatusProbe: GitStatusProbe = defaultGitStatusProbe;
639
+
640
+ /** Test seam: replace the real git subprocess probe (pass null to reset). */
641
+ export function _setGitStatusProbeForTest(probe: GitStatusProbe | null): void {
642
+ gitStatusProbe = probe ?? defaultGitStatusProbe;
643
+ }
644
+
645
+ /** Probe the working tree once per WORK_REFRESH_MS, suppressing concurrent
646
+ * probes. The first probe (per process) is awaited by the heartbeat so the
647
+ * initial presence is accurate; later refreshes are detached — a slow git
648
+ * must not delay presence, and the 10s subprocess timeout bounds the worst
649
+ * case anyway. Best-effort: a non-repo cwd simply keeps gitAvailable=false
650
+ * and an empty list, which the list renderer distinguishes from a
651
+ * verified-clean tree. */
652
+ function refreshGitStatus(state: BrokerState, now: number = Date.now()): Promise<void> {
653
+ if (state.gitProbing || now - state.gitProbedAt < WORK_REFRESH_MS) return Promise.resolve();
654
+ state.gitProbing = true;
655
+ const firstProbe = state.gitProbedAt === 0;
656
+ const run = gitStatusProbe(state.cwd)
657
+ .then((result) => {
658
+ state.gitAvailable = result.gitAvailable;
659
+ state.changedFiles = result.changedFiles;
660
+ })
661
+ .catch(() => {
662
+ state.gitAvailable = false;
663
+ state.changedFiles = [];
664
+ })
665
+ .finally(() => {
666
+ state.gitProbing = false;
667
+ state.gitProbedAt = Date.now();
668
+ });
669
+ return firstProbe ? run : Promise.resolve();
670
+ }
671
+
523
672
  async function heartbeat(state: BrokerState): Promise<void> {
524
673
  // sessions[] and roster[] are same-source: ids collected from the
525
674
  // TASK_SUBAGENT_EVENT_CHANNEL subscription, always unioned with the
@@ -530,11 +679,22 @@ async function heartbeat(state: BrokerState): Promise<void> {
530
679
  // extensions, which guarantees both lists are never empty.
531
680
  const roster = Array.from(new Set([MAIN_AGENT_ID, ...state.agents])).sort();
532
681
  const sessions = Array.from(new Set([state.brokerId, ...state.agents])).sort();
682
+ // Task summaries in roster order; stale map entries (ids already pruned
683
+ // from agents) cannot appear.
684
+ const tasks = roster.slice(1).map((id) => state.taskById.get(id)).filter((t): t is string => !!t);
685
+ // First probe is awaited (accurate initial presence); later ones detach
686
+ // and the heartbeat publishes the last cached snapshot.
687
+ await refreshGitStatus(state);
533
688
  await writePresence(state.root, state.brokerId, {
534
689
  pid: process.pid,
535
690
  startedAt: state.startedAt,
536
691
  sessions,
537
692
  roster,
693
+ work: {
694
+ tasks: tasks.slice(0, WORK_TASK_MAX),
695
+ changedFiles: state.changedFiles,
696
+ gitAvailable: state.gitAvailable,
697
+ },
538
698
  });
539
699
  }
540
700
 
@@ -556,12 +716,57 @@ function trackSubagentFrame(state: BrokerState, payload: unknown): void {
556
716
  function trackSubagentLifecycle(state: BrokerState, payload: unknown): void {
557
717
  try {
558
718
  if (!payload || typeof payload !== 'object') return;
559
- const p = payload as { id?: unknown; status?: unknown };
719
+ const p = payload as { id?: unknown; status?: unknown; description?: unknown };
560
720
  if (typeof p.id !== 'string' || !p.id) return;
561
721
  if (p.status === 'completed' || p.status === 'failed' || p.status === 'aborted') {
562
722
  state.agents.delete(p.id);
723
+ state.taskById.delete(p.id);
563
724
  } else if (p.status === 'started') {
564
725
  state.agents.add(p.id);
726
+ // Detached spawns may carry a pre-set UI description; the
727
+ // progress channel's assignment supersedes it when it arrives.
728
+ if (typeof p.description === 'string' && p.description && !state.taskById.has(p.id)) {
729
+ state.taskById.set(p.id, summarizeTask(p.description));
730
+ }
731
+ }
732
+ } catch {
733
+ /* best-effort */
734
+ }
735
+ }
736
+
737
+ /** Capture one-line summaries of active subagent work from the progress
738
+ * channel. The payload's `assignment` is the caller-authored task text —
739
+ * the most reliable "what is this broker doing" signal peers need to pick
740
+ * a 1:1 recipient. Terminal progress (completion/failure/abort) prunes. */
741
+ function trackSubagentProgress(state: BrokerState, payload: unknown): void {
742
+ try {
743
+ if (!payload || typeof payload !== 'object') return;
744
+ // The wire payload carries the spawn index/agent, but the stable
745
+ // registry id lives inside the progress object itself (same join the
746
+ // harness's rpc-subagents uses to key these frames).
747
+ const p = payload as {
748
+ assignment?: unknown;
749
+ progress?: { id?: unknown; status?: unknown; description?: unknown };
750
+ };
751
+ const id = p.progress?.id;
752
+ if (typeof id !== 'string' || !id) return;
753
+ const status = p.progress?.status;
754
+ if (status === 'completed' || status === 'failed' || status === 'aborted') {
755
+ state.agents.delete(id);
756
+ state.taskById.delete(id);
757
+ return;
758
+ }
759
+ // Prefer the caller-authored assignment; fall back to the tiny-model
760
+ // description the monitor generates from it.
761
+ const source =
762
+ typeof p.assignment === 'string' && p.assignment
763
+ ? p.assignment
764
+ : typeof p.progress?.description === 'string'
765
+ ? p.progress.description
766
+ : undefined;
767
+ if (source) {
768
+ state.agents.add(id);
769
+ state.taskById.set(id, summarizeTask(source));
565
770
  }
566
771
  } catch {
567
772
  /* best-effort */
@@ -583,27 +788,30 @@ async function ensureBrokerStarted(ctx: ExtensionContext): Promise<BrokerState>
583
788
  await ensureLayouts(state.root);
584
789
 
585
790
  // Roster/sessions mirror: event channel adds active ids; the lifecycle
586
- // channel prunes them on completion/failure/abort. Same eventBus hack
587
- // as index.ts's subagent usage subscription; channel constants are
588
- // imported from the harness, not hardcoded.
791
+ // channel prunes them on completion/failure/abort; the progress channel
792
+ // carries the caller-authored assignment, which becomes the task summary
793
+ // published to peers. Same eventBus hack as index.ts's subagent usage
794
+ // subscription; channel constants are imported from the harness.
589
795
  const eventBus = getEventBusFromCtx(ctx);
590
796
  if (eventBus) {
591
- const unsubEvent = subscribeEventBus(eventBus, TASK_SUBAGENT_EVENT_CHANNEL, (payload) =>
592
- trackSubagentFrame(state, payload),
593
- );
594
- const unsubLifecycle = subscribeEventBus(eventBus, TASK_SUBAGENT_LIFECYCLE_CHANNEL, (payload) =>
595
- trackSubagentLifecycle(state, payload),
596
- );
797
+ const unsubs = [
798
+ subscribeEventBus(eventBus, TASK_SUBAGENT_EVENT_CHANNEL, (payload) =>
799
+ trackSubagentFrame(state, payload),
800
+ ),
801
+ subscribeEventBus(eventBus, TASK_SUBAGENT_LIFECYCLE_CHANNEL, (payload) =>
802
+ trackSubagentLifecycle(state, payload),
803
+ ),
804
+ subscribeEventBus(eventBus, TASK_SUBAGENT_PROGRESS_CHANNEL, (payload) =>
805
+ trackSubagentProgress(state, payload),
806
+ ),
807
+ ];
597
808
  state.unsubscribe = () => {
598
- try {
599
- unsubEvent?.();
600
- } catch {
601
- /* best-effort */
602
- }
603
- try {
604
- unsubLifecycle?.();
605
- } catch {
606
- /* best-effort */
809
+ for (const unsub of unsubs) {
810
+ try {
811
+ unsub?.();
812
+ } catch {
813
+ /* best-effort */
814
+ }
607
815
  }
608
816
  };
609
817
  }
@@ -673,7 +881,7 @@ interface WaitToolParams {
673
881
  // Exported so tests can assert the contract verbatim.
674
882
 
675
883
  export const PIPE_SEND_DESCRIPTION =
676
- 'Fire-and-forget write of a text message to the target broker\'s mbox. Cross-broker pipe: brokers are PEERS — each broker has its own repo and its own human owner. Messages are for alignment only: asking questions, negotiating, syncing information. You MUST NOT assign tasks to another broker or ask it to implement changes; work in another broker\'s repo is driven by that broker\'s human. `to.broker: "all"` fans out to every currently-online peer (excludes self; no mbox/all/ dead letter). Validates the deliver field { mode: steer|followUp|nextTurn, triggerTurn } (defaults followUp/true); core stores but never injects it. body is capped at 32KB.';
884
+ 'Fire-and-forget 1:1 write of a text message to ONE target broker\'s mbox — there is deliberately no broadcast/fanout: pick the recipient from opsx_pipe_list (whose per-broker summary shows current tasks and modified files) and address exactly that broker. Cross-broker pipe: brokers are PEERS — each broker has its own repo and its own human owner. Messages are for alignment only: asking questions, negotiating, syncing information. You MUST NOT assign tasks to another broker or ask it to implement changes; work in another broker\'s repo is driven by that broker\'s human. Validates the deliver field { mode: steer|followUp|nextTurn, triggerTurn } (defaults followUp/true); core stores but never injects it. body is capped at 32KB.';
677
885
 
678
886
  /** Peer-alignment reminder appended to the pull-tool descriptions. */
679
887
  const PEER_PULL_HINT =
@@ -695,7 +903,7 @@ export function registerPipeCore(pi: ExtensionAPI): void {
695
903
  name: 'opsx_pipe_list',
696
904
  label: 'Pipe: list brokers',
697
905
  description:
698
- '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.',
906
+ 'List online brokers (omp sessions open in this project) and, for each, a summary of what it is currently working on (active subagent task lines) and the files its repo has modified (git status). Use this to identify WHICH single broker to address with opsx_pipe_send — communication is strictly 1:1; there is no broadcast. Brokers whose heartbeat is older than 30s are filtered out as offline.',
699
907
  parameters: z.object({}),
700
908
  approval: 'read',
701
909
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
@@ -704,10 +912,27 @@ export function registerPipeCore(pi: ExtensionAPI): void {
704
912
  return textResult(
705
913
  [
706
914
  `Online brokers: ${brokers.length}`,
707
- ...brokers.map(
708
- (b) =>
709
- `- ${b.broker}${b.broker === state.brokerId ? ' (self)' : ''} roster: [${b.roster.join(', ')}] sessions: [${b.sessions.join(', ')}]`,
710
- ),
915
+ ...brokers.flatMap((b) => {
916
+ const head =
917
+ `- ${b.broker}${b.broker === state.brokerId ? ' (self)' : ''}` +
918
+ ` roster: [${b.roster.join(', ')}] sessions: [${b.sessions.join(', ')}]`;
919
+ const lines = [head];
920
+ for (const t of b.work.tasks) lines.push(` task: ${t}`);
921
+ if (b.work.tasks.length === 0) lines.push(' task: (no active subagent task)');
922
+ if (b.work.changedFiles.length > 0) {
923
+ // One path per line is far more model-scannable
924
+ // than a comma wall; cap rendered lines while the
925
+ // full capped list stays in the structured details.
926
+ const shown = b.work.changedFiles.slice(0, CHANGED_FILE_RENDER_MAX);
927
+ lines.push(` changed files (${b.work.changedFiles.length}):`);
928
+ for (const f of shown) lines.push(` ${f}`);
929
+ const hidden = b.work.changedFiles.length - shown.length;
930
+ if (hidden > 0) lines.push(` ... and ${hidden} more`);
931
+ } else if (b.work.gitAvailable) {
932
+ lines.push(' changed files: (clean working tree)');
933
+ }
934
+ return lines;
935
+ }),
711
936
  ].join('\n'),
712
937
  { brokers },
713
938
  );
@@ -724,12 +949,12 @@ export function registerPipeCore(pi: ExtensionAPI): void {
724
949
  broker: z
725
950
  .string()
726
951
  .describe(
727
- 'Target broker id (a primary session id; see opsx_pipe_list), or "all" to fan out to every other online broker',
952
+ 'Exactly one target broker id (a primary session id from opsx_pipe_list). No broadcast exists: inspect the per-broker task/file summaries in opsx_pipe_list and choose the single right peer.',
728
953
  ),
729
954
  session: z
730
955
  .string()
731
956
  .optional()
732
- .describe('Optional target agent/session for hub relay (not allowed with broker "all")'),
957
+ .describe('Optional target agent/session inside the target broker for last-mile relay'),
733
958
  }),
734
959
  deliver: z
735
960
  .object({
@@ -752,26 +977,11 @@ export function registerPipeCore(pi: ExtensionAPI): void {
752
977
  const p = params as SendToolParams;
753
978
  const state = await ensureBrokerStarted(ctx);
754
979
  if (p.to.broker === 'all') {
755
- if (p.to.session !== undefined) {
756
- throw new Error('to.session is not supported with to.broker "all"');
757
- }
758
- const messages = await broadcastMessage(state.root, {
759
- fromBroker: state.brokerId,
760
- deliver: p.deliver,
761
- type: p.type,
762
- body: p.body,
763
- replyTo: p.replyTo,
764
- });
765
- const targets = messages.map((m) => m.to.broker);
766
- return textResult(
767
- messages.length === 0
768
- ? 'Broadcast to all: no other online brokers.'
769
- : `Broadcast ${messages.length} message(s) to: ${targets.join(', ')}`,
770
- {
771
- broadcast: true,
772
- ids: messages.map((m) => m.id),
773
- to: targets,
774
- },
980
+ // Belt and suspenders: the tool description forbids it and
981
+ // sendMessage rejects it, but fail loudly here too so a model
982
+ // that tries broadcasting gets an explicit corrective error.
983
+ throw new Error(
984
+ 'to.broker "all" is not supported: the pipe is 1:1 only. Call opsx_pipe_list, read each broker\'s task/file summary, and address the one right peer.',
775
985
  );
776
986
  }
777
987
  const msg = await sendMessage(state.root, {
@@ -851,7 +1061,7 @@ export function registerPipeCore(pi: ExtensionAPI): void {
851
1061
 
852
1062
  // registerTool returns void and silently skips name conflicts — log
853
1063
  // registration so collision with another extension is diagnosable.
854
- pi.logger?.debug?.('[opsx-pipe] registered 4 tools');
1064
+ pi.logger?.debug?.('[opsx-pipe] registered 4 tools (1:1 only; no broadcast)');
855
1065
 
856
1066
  // Heartbeat/GC lifecycle: start on session_start, tear down on shutdown.
857
1067
  pi.on('session_start', async (_event, ctx) => {