@genee/omp-opsx-addon 0.6.0 → 0.7.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
@@ -11,8 +11,9 @@
11
11
  // The broker-id is the primary session id (`ctx.sessionManager.getSessionId()`).
12
12
  // This module implements the CORE only: directory layout, presence heartbeat,
13
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
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
16
17
  // opsx-pipe-push).
17
18
  import { promises as fs } from 'fs';
18
19
  import { join } from 'path';
@@ -237,6 +238,13 @@ export interface SendInput {
237
238
  export async function sendMessage(root: string, input: SendInput): Promise<PipeMessage> {
238
239
  assertBrokerId(input.fromBroker, 'from.broker');
239
240
  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.
243
+ if (input.toBroker === 'all') {
244
+ throw new Error(
245
+ 'to.broker "all" is reserved for broadcast fanout; refuse to write mbox/all/',
246
+ );
247
+ }
240
248
  assertBody(input.body);
241
249
  if (input.toSession !== undefined && typeof input.toSession !== 'string') {
242
250
  throw new Error('to.session must be a string when provided');
@@ -265,6 +273,38 @@ export async function sendMessage(root: string, input: SendInput): Promise<PipeM
265
273
  return msg;
266
274
  }
267
275
 
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
+
268
308
  // Process-internal idempotency: consumed msg-ids per (root, broker). Cross-
269
309
  // process, the atomic rename guarantees a single consumer wins a given file;
270
310
  // worst case a redelivered id surfaces once per process (at-least-once).
@@ -659,11 +699,18 @@ export function registerPipeCore(pi: ExtensionAPI): void {
659
699
  name: 'opsx_pipe_send',
660
700
  label: 'Pipe: send message',
661
701
  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.',
702
+ 'Fire-and-forget a text message to another broker\'s mbox (same semantics as hub send). `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.',
663
703
  parameters: z.object({
664
704
  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'),
705
+ broker: z
706
+ .string()
707
+ .describe(
708
+ 'Target broker id (a primary session id; see opsx_pipe_list), or "all" to fan out to every other online broker',
709
+ ),
710
+ session: z
711
+ .string()
712
+ .optional()
713
+ .describe('Optional target agent/session for hub relay (not allowed with broker "all")'),
667
714
  }),
668
715
  deliver: z
669
716
  .object({
@@ -685,6 +732,29 @@ export function registerPipeCore(pi: ExtensionAPI): void {
685
732
  // explicitly. normalizeDeliver re-validates deliver at runtime.
686
733
  const p = params as SendToolParams;
687
734
  const state = await ensureBrokerStarted(ctx);
735
+ if (p.to.broker === 'all') {
736
+ if (p.to.session !== undefined) {
737
+ throw new Error('to.session is not supported with to.broker "all"');
738
+ }
739
+ const messages = await broadcastMessage(state.root, {
740
+ fromBroker: state.brokerId,
741
+ deliver: p.deliver,
742
+ type: p.type,
743
+ body: p.body,
744
+ replyTo: p.replyTo,
745
+ });
746
+ const targets = messages.map((m) => m.to.broker);
747
+ return textResult(
748
+ messages.length === 0
749
+ ? 'Broadcast to all: no other online brokers.'
750
+ : `Broadcast ${messages.length} message(s) to: ${targets.join(', ')}`,
751
+ {
752
+ broadcast: true,
753
+ ids: messages.map((m) => m.id),
754
+ to: targets,
755
+ },
756
+ );
757
+ }
688
758
  const msg = await sendMessage(state.root, {
689
759
  fromBroker: state.brokerId,
690
760
  toBroker: p.to.broker,
package/lib/pipe-push.ts CHANGED
@@ -15,6 +15,16 @@
15
15
  //
16
16
  // No self-built queue: steer/followUp/nextTurn ordering and idle wake-ups
17
17
  // are entirely the harness's native deliverAs/triggerTurn semantics.
18
+ //
19
+ // One deliberate deviation: while the session is mid-turn AND parked in an
20
+ // interruptible wait (hub `wait`, or hub `logs` with follow — the only tool
21
+ // calls the harness aborts on a steer, see the hub tool's interruptible
22
+ // predicate), a followUp is upgraded to steer. A followUp otherwise drains
23
+ // only at the turn's yield boundary (minutes away when the model is parked
24
+ // waiting on peers); a steer aborts the in-flight wait and is fed to the
25
+ // model before its next request. Every other busy state (model streaming,
26
+ // bash, ask, extension tools) keeps native followUp semantics — a steer
27
+ // there cannot preempt the running tool anyway.
18
28
  import { promises as fs, watch } from 'fs';
19
29
  import { join } from 'path';
20
30
  import type { ExtensionAPI, ExtensionContext } from '@oh-my-pi/pi-coding-agent';
@@ -103,6 +113,44 @@ function isPipeMessage(value: unknown): value is PipeMessage {
103
113
  return typeof from.broker === 'string' && typeof to.broker === 'string';
104
114
  }
105
115
 
116
+ // ── busy-wait detection (followUp → steer upgrade) ───────────────────
117
+
118
+ /** In-flight tool call tracked for upgrade purposes: the only predicate that
119
+ * matters is whether a steer aborts it mid-flight. The harness's hub tool
120
+ * declares exactly two such calls (its `interruptible` predicate):
121
+ * `hub {op:"wait"}` and `hub {op:"logs", follow:true}`. */
122
+ interface TrackedTool {
123
+ toolName: string;
124
+ args?: unknown;
125
+ }
126
+
127
+ /** Per-broker view of session liveness, fed by turn_start/turn_end and
128
+ * tool_execution_start/end events. A broker is "busy-waiting" when a turn
129
+ * is open AND at least one in-flight tool call is an interruptible wait. */
130
+ export interface BusyState {
131
+ inTurn: boolean;
132
+ tools: Map<string, TrackedTool>;
133
+ }
134
+
135
+ function isInterruptibleWait(tool: TrackedTool): boolean {
136
+ if (tool.toolName !== 'hub') return false;
137
+ const args = (tool.args ?? {}) as { op?: unknown; follow?: unknown };
138
+ if (args.op === 'wait') return true;
139
+ return args.op === 'logs' && args.follow === true;
140
+ }
141
+
142
+ /** True while the session is mid-turn and parked in an interruptible wait:
143
+ * the one busy state where delivering as steer (instead of followUp) makes
144
+ * the message reach the model before the turn yields. */
145
+ export function isBusyWaiting(state: BusyState): boolean {
146
+ if (!state.inTurn) return false;
147
+
148
+ for (const tool of state.tools.values()) {
149
+ if (isInterruptibleWait(tool)) return true;
150
+ }
151
+ return false;
152
+ }
153
+
106
154
  // ── per-broker runtime state ─────────────────────────────────────────
107
155
 
108
156
  interface PushState {
@@ -110,6 +158,8 @@ interface PushState {
110
158
  root: string;
111
159
  /** Stop-injection flag; default false = injection enabled. */
112
160
  muted: boolean;
161
+ /** Session liveness for the followUp→steer upgrade (see isBusyWaiting). */
162
+ busy: BusyState;
113
163
  watcher?: WatchHandle;
114
164
  /** True once the watcher failed; the 30s tick then carries all scans. */
115
165
  degraded: boolean;
@@ -136,9 +186,18 @@ function setOwner(brokerId: string | undefined): void {
136
186
  * Inject one message via pi.sendMessage (custom entry, never spoofed as a
137
187
  * human). deliver.mode maps 1:1 to deliverAs; triggerTurn is passed through;
138
188
  * missing deliver defaults to followUp/true (core normalizeDeliver).
189
+ *
190
+ * Upgrade: a followUp landing while the session is busy-waiting (mid-turn,
191
+ * parked in an interruptible hub wait) is delivered as steer instead. The
192
+ * steer aborts the wait so the model sees this message at its next request
193
+ * rather than after the whole turn yields. steer/nextTurn pass through
194
+ * untouched — a sender that asked for steer wants it everywhere; nextTurn is
195
+ * explicitly deferred by the sender.
139
196
  */
140
- async function injectMessage(msg: PipeMessage, send: SendFn): Promise<void> {
197
+ async function injectMessage(msg: PipeMessage, state: PushState, send: SendFn): Promise<void> {
141
198
  const deliver = normalizeDeliver(msg.deliver);
199
+ const upgraded = deliver.mode === 'followUp' && isBusyWaiting(state.busy);
200
+ const deliverAs: DeliverMode = upgraded ? 'steer' : deliver.mode;
142
201
  await Promise.resolve(
143
202
  send(
144
203
  {
@@ -153,9 +212,10 @@ async function injectMessage(msg: PipeMessage, send: SendFn): Promise<void> {
153
212
  ts: msg.ts,
154
213
  ...(msg.replyTo ? { replyTo: msg.replyTo } : {}),
155
214
  deliver,
215
+ ...(upgraded ? { upgradedToSteer: true } : {}),
156
216
  },
157
217
  },
158
- { deliverAs: deliver.mode, triggerTurn: deliver.triggerTurn },
218
+ { deliverAs, triggerTurn: deliver.triggerTurn },
159
219
  ),
160
220
  );
161
221
  }
@@ -196,7 +256,7 @@ export async function scanOnce(state: PushState, send: SendFn): Promise<{ scanne
196
256
  if (!msg) continue; // corrupt: removed by the claim step, never injected
197
257
  state.seen.add(id);
198
258
  try {
199
- await injectMessage(msg, send);
259
+ await injectMessage(msg, state, send);
200
260
  injected += 1;
201
261
  } catch {
202
262
  // Injection failed: do NOT claim — the file stays for the next
@@ -227,7 +287,7 @@ export async function scanOnce(state: PushState, send: SendFn): Promise<{ scanne
227
287
  // file is valid (consume parsed it) — deliver it now.
228
288
  state.seen.add(m.id);
229
289
  try {
230
- await injectMessage(m, send);
290
+ await injectMessage(m, state, send);
231
291
  injected += 1;
232
292
  } catch {
233
293
  state.seen.delete(m.id);
@@ -294,6 +354,7 @@ export async function startPipePush(
294
354
  brokerId,
295
355
  root,
296
356
  muted: false,
357
+ busy: { inTurn: false, tools: new Map() },
297
358
  degraded: false,
298
359
  seen: new Set(),
299
360
  scanChain: Promise.resolve(),
@@ -357,6 +418,14 @@ export function isMuted(brokerId: string): boolean {
357
418
  return states.get(brokerId)?.muted ?? false;
358
419
  }
359
420
 
421
+ /** Resolve the push state for the ExtensionContext's session, if any.
422
+ * Subagent sessions share the process but never own a PushState, so their
423
+ * turn/tool events are ignored (no state → no-op). */
424
+ function stateForCtx(ctx: ExtensionContext): PushState | undefined {
425
+ const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
426
+ return brokerId ? states.get(brokerId) : undefined;
427
+ }
428
+
360
429
  // ── plugin wiring ────────────────────────────────────────────────────
361
430
 
362
431
  export function registerPipePush(pi: ExtensionAPI): void {
@@ -424,6 +493,36 @@ export function registerPipePush(pi: ExtensionAPI): void {
424
493
  /* best-effort */
425
494
  }
426
495
  });
496
+
497
+ // Busy-wait tracking: only the owning primary session has PushState, so
498
+ // subagent turn/tool events (different session id) are no-ops. turn_end
499
+ // clears the whole tools map in case a tool_execution_end was missed
500
+ // (e.g. abort mid-batch).
501
+ pi.on('turn_start', (_event, ctx) => {
502
+ const state = stateForCtx(ctx);
503
+ if (!state) return;
504
+ state.busy.inTurn = true;
505
+ });
506
+ pi.on('turn_end', (_event, ctx) => {
507
+ const state = stateForCtx(ctx);
508
+ if (!state) return;
509
+ state.busy.inTurn = false;
510
+ state.busy.tools.clear();
511
+ });
512
+ pi.on('tool_execution_start', (event, ctx) => {
513
+ const state = stateForCtx(ctx);
514
+ if (!state) return;
515
+ const e = event as { toolCallId?: string; toolName?: string; args?: unknown };
516
+ if (!e.toolCallId || !e.toolName) return;
517
+ state.busy.tools.set(e.toolCallId, { toolName: e.toolName, args: e.args });
518
+ });
519
+ pi.on('tool_execution_end', (event, ctx) => {
520
+ const state = stateForCtx(ctx);
521
+ if (!state) return;
522
+ const e = event as { toolCallId?: string };
523
+ if (!e.toolCallId) return;
524
+ state.busy.tools.delete(e.toolCallId);
525
+ });
427
526
  }
428
527
 
429
528
  // ── test-only reset ──────────────────────────────────────────────────
@@ -441,6 +540,20 @@ export function _getStateForTest(brokerId: string): PushState | undefined {
441
540
  return states.get(brokerId);
442
541
  }
443
542
 
543
+ /** Test seam: overwrite busy-wait tracking for a broker (scanOnce upgrade tests). */
544
+ export function _setBusyForTest(
545
+ brokerId: string,
546
+ busy: { inTurn: boolean; tools?: Array<{ toolCallId: string; toolName: string; args?: unknown }> },
547
+ ): void {
548
+ const state = states.get(brokerId);
549
+ if (!state) throw new Error(`_setBusyForTest: no state for ${brokerId}`);
550
+ state.busy.inTurn = busy.inTurn;
551
+ state.busy.tools.clear();
552
+ for (const t of busy.tools ?? []) {
553
+ state.busy.tools.set(t.toolCallId, { toolName: t.toolName, args: t.args });
554
+ }
555
+ }
556
+
444
557
  /** Test seam: await the settled scan chain (doorbell/tick scans are async). */
445
558
  export async function _flushForTest(brokerId: string): Promise<void> {
446
559
  const state = states.get(brokerId);
@@ -457,6 +570,7 @@ export async function _makeStateForTest(brokerId: string, cwd: string): Promise<
457
570
  brokerId,
458
571
  root,
459
572
  muted: false,
573
+ busy: { inTurn: false, tools: new Map() },
460
574
  degraded: true, // no watcher in this harness
461
575
  seen: new Set(),
462
576
  scanChain: Promise.resolve(),
@@ -177,10 +177,29 @@ const unconfirmedProviders = new Set<string>();
177
177
  * reads would re-add the provider to {@link unconfirmedProviders} and hammer it.
178
178
  */
179
179
  const confirmedThisProcess = new Set<string>();
180
+ /**
181
+ * Timestamp of the most recent fetch that actually delivered a report per
182
+ * provider (canonical id → epoch ms). This — NOT `lastFetchedByProvider`,
183
+ * which a successful-but-empty fetch also advances for TTL — is the local
184
+ * timestamp used by the locked fetch-file LWW merge, so a null fetch after a
185
+ * prior confirmation can never pair a stale in-memory report with a fresh
186
+ * attempt timestamp and clobber a newer report another broker just wrote.
187
+ */
188
+ const confirmedAtByProvider = new Map<string, number>();
180
189
  /** Canonical ids of providers covered by a direct fetcher (the fresh source). */
181
190
  const directFetcherIds = new Set<string>();
182
191
  let cachedReports: UsageReport[] = [];
183
192
  let cachedHealth: Map<string, ProviderHealth> = new Map();
193
+ /**
194
+ * Most recently computed logged-in provider universe (start sequence + every
195
+ * data tick). The 1s render path (`getSharedUsage`) has no auth context of its
196
+ * own, so it filters the on-disk union through THIS cached set: reports for
197
+ * providers this broker is not logged into never leak into the widget even
198
+ * though the shared fetch file retains them for other brokers. `null` = poller
199
+ * never started (tests / direct callers) → no filtering, matching the legacy
200
+ * unfiltered behavior.
201
+ */
202
+ let lastLoggedInSet: ReadonlySet<string> | null = null;
184
203
  /** Single interval: renders every tick AND checks absolute elapsed time for the data refresh. */
185
204
  let renderTimer: NodeJS.Timeout | null = null;
186
205
  /**
@@ -653,16 +672,51 @@ function writeRealtimeDisk(deltas: Record<string, ProviderUsageTotals>): boolean
653
672
  }
654
673
 
655
674
  /**
656
- * Fetch write path: serializes the in-memory fetch mirror under the same lock
657
- * protocol. Never reads or rewrites the realtime aggregates.
675
+ * Fetch write path — a LOCKED read-modify-write (same lock protocol as
676
+ * {@link writeRealtimeDisk}): inside the lock we re-read the freshest on-disk
677
+ * fetch file and merge this process's contributions on top.
678
+ *
679
+ * Merge scope is ONLY the providers THIS process actually CONFIRMED with a
680
+ * fresh report (`confirmedThisProcess`). `lastFetchedByProvider` also holds
681
+ * entries for fetches that succeeded WITHOUT a report (TTL advancement only —
682
+ * those never land in `confirmedThisProcess`) and entries seeded from disk;
683
+ * iterating it would write phantom timestamp-only entries and overwrite
684
+ * possibly-newer disk reports with stale in-memory ones.
685
+ *
686
+ * Per confirmed provider, last-writer-wins by millisecond timestamp: this
687
+ * process's report replaces (or inserts) the disk entry only when its
688
+ * CONFIRMED timestamp (`confirmedAtByProvider` — the fetch that actually
689
+ * delivered THIS report; a later null fetch does not advance it) is `>=` the
690
+ * disk timestamp for that provider; the `fetchedByProvider` entry is written
691
+ * ONLY when the report is actually written. Providers present on disk but not
692
+ * written this cycle
693
+ * (other brokers' providers, unconfirmed fetches, seeded reports) are
694
+ * preserved verbatim — the shared file is the cross-broker UNION and is never
695
+ * pruned (see {@link pruneDisconnected}, in-memory only). `fetchedAt` is the
696
+ * max across disk and this process's timestamps. Never reads or rewrites the
697
+ * realtime aggregates.
658
698
  */
659
699
  function writeFetchDisk(): boolean {
660
- const fetchedByProvider: Record<string, number> = {};
661
- for (const [p, t] of lastFetchedByProvider) fetchedByProvider[p] = t;
662
- return writeFetchPayload({
663
- fetchedAt: globalFetchedAt(),
664
- reports: cachedReports,
665
- fetchedByProvider,
700
+ return writePayloadLocked(resolveFetchFile(), () => {
701
+ const disk = readFetchDisk() ?? { fetchedAt: 0, reports: [] as UsageReport[], fetchedByProvider: {} as Record<string, number> };
702
+ const fetchedByProvider: Record<string, number> = { ...(disk.fetchedByProvider ?? {}) };
703
+ // Base: disk reports keyed by CANONICAL provider id (readFetchDisk
704
+ // canonicalizes disk ids; normalize defensively for the map key).
705
+ const reportsById = new Map<string, UsageReport>();
706
+ for (const r of disk.reports) reportsById.set(canonicalizeProvider(r.provider), r);
707
+
708
+ let fetchedAt = disk.fetchedAt ?? 0;
709
+ for (const provider of confirmedThisProcess) {
710
+ const localTs = confirmedAtByProvider.get(provider) ?? 0;
711
+ const diskTs = fetchedByProvider[provider] ?? 0;
712
+ if (localTs < diskTs) continue; // disk is newer — keep its report/timestamp
713
+ const report = cachedReports.find((r) => r.provider === provider);
714
+ if (!report) continue; // confirmed but no in-memory report: defensive skip
715
+ reportsById.set(provider, report);
716
+ fetchedByProvider[provider] = localTs;
717
+ if (localTs > fetchedAt) fetchedAt = localTs;
718
+ }
719
+ return JSON.stringify({ fetchedAt, reports: [...reportsById.values()], fetchedByProvider }, null, 2);
666
720
  });
667
721
  }
668
722
 
@@ -825,30 +879,36 @@ function seedFromDisk(loggedInProviders?: ReadonlySet<string>): void {
825
879
  for (const r of reports) lastFetchedByProvider.set(r.provider, disk.fetchedAt);
826
880
  }
827
881
  }
828
- /** Remove stale in-memory AND on-disk cached data for any provider NOT in `loggedIn`. */
882
+ /**
883
+ * Trim THIS process's in-memory caches to the logged-in universe:
884
+ * dirtyProviders / lastAttemptByProvider / lastFetchedByProvider /
885
+ * unconfirmedProviders / confirmedThisProcess(+confirmedAtByProvider) /
886
+ * cachedReports(+health) for any
887
+ * provider NOT in `loggedIn`. Memory/display semantics ONLY — the shared fetch
888
+ * file is the cross-broker UNION of provider reports, so this MUST NOT write
889
+ * back or delete any disk entry (another broker may own the pruned provider).
890
+ */
829
891
  function pruneDisconnected(loggedIn: ReadonlySet<string>): void {
830
- let removed = false;
831
892
  for (const key of dirtyProviders) {
832
- if (!loggedIn.has(key)) { dirtyProviders.delete(key); removed = true; }
893
+ if (!loggedIn.has(key)) dirtyProviders.delete(key);
833
894
  }
834
895
  for (const key of lastAttemptByProvider.keys()) {
835
- if (!loggedIn.has(key)) { lastAttemptByProvider.delete(key); removed = true; }
896
+ if (!loggedIn.has(key)) lastAttemptByProvider.delete(key);
836
897
  }
837
898
  for (const key of lastFetchedByProvider.keys()) {
838
- if (!loggedIn.has(key)) { lastFetchedByProvider.delete(key); removed = true; }
899
+ if (!loggedIn.has(key)) lastFetchedByProvider.delete(key);
839
900
  }
840
901
  for (const key of unconfirmedProviders) {
841
- if (!loggedIn.has(key)) { unconfirmedProviders.delete(key); removed = true; }
902
+ if (!loggedIn.has(key)) unconfirmedProviders.delete(key);
842
903
  }
843
904
  for (const key of confirmedThisProcess) {
844
- if (!loggedIn.has(key)) { confirmedThisProcess.delete(key); removed = true; }
905
+ if (!loggedIn.has(key)) {
906
+ confirmedThisProcess.delete(key);
907
+ confirmedAtByProvider.delete(key);
908
+ }
845
909
  }
846
- const next = cachedReports.filter((r) => loggedIn.has(r.provider));
847
- removed = removed || next.length !== cachedReports.length;
848
- cachedReports = next;
910
+ cachedReports = cachedReports.filter((r) => loggedIn.has(r.provider));
849
911
  cachedHealth = buildHealthMap(cachedReports);
850
- // Persist only when something was actually dropped — the 5s sync tick must
851
- if (removed) writeFetchDisk();
852
912
  }
853
913
  /** Merge `incoming` into `existing` by canonical provider id (incoming wins). */
854
914
  function mergeReports(existing: UsageReport[], incoming: UsageReport[]): UsageReport[] {
@@ -914,6 +974,11 @@ async function fetchProvider(
914
974
  // Fresh report landed → the provider's health is CONFIRMED.
915
975
  unconfirmedProviders.delete(canonical);
916
976
  confirmedThisProcess.add(canonical);
977
+ // The timestamp bound to THIS report — the LWW clock for the
978
+ // locked fetch-file merge. NOT updated by the null-report branch
979
+ // below (which advances lastFetchedByProvider for TTL only), so a
980
+ // later empty fetch cannot overwrite a newer disk report.
981
+ confirmedAtByProvider.set(canonical, fetchedAt);
917
982
  } else {
918
983
  // Fetch produced nothing for this provider: any existing report is
919
984
  // UNCONFIRMED (possibly stale) — keep it for display, but flag it so
@@ -1061,12 +1126,26 @@ export async function startSharedPoller(
1061
1126
 
1062
1127
  // One-time migration from the legacy single cache file (idempotent).
1063
1128
  migrateLegacyCache();
1064
- const loggedInSet = new Set(getLoggedInProviders(authStorage, directFetchers));
1065
1129
  setDirectFetcherCoverage(directFetchers);
1066
- syncFromDisk(loggedInSet);
1067
- seedFromDisk(loggedInSet);
1130
+ // Logged-in universe: recomputed at start AND on every data tick — it
1131
+ // is a cheap local enumeration (authStorage.list + local credential
1132
+ // probes, no network), so a provider whose credentials appear AFTER
1133
+ // poller start (late authStorage, env credential, runtime login)
1134
+ // enters the universe next tick instead of being pruned forever and
1135
+ // oscillating against the broker that owns it. Each recompute also
1136
+ // refreshes `lastLoggedInSet`, the cached filter the 1s render path
1137
+ // (getSharedUsage) applies to the on-disk union.
1138
+ const computeLoggedIn = (): Set<string> => {
1139
+ const set = new Set(getLoggedInProviders(authStorage, directFetchers));
1140
+ lastLoggedInSet = set;
1141
+ return set;
1142
+ };
1143
+ const initialLoggedIn = computeLoggedIn();
1144
+ syncFromDisk(initialLoggedIn);
1145
+ seedFromDisk(initialLoggedIn);
1068
1146
 
1069
1147
  const tick = async (): Promise<void> => {
1148
+ const loggedInSet = computeLoggedIn();
1070
1149
  syncFromDisk(loggedInSet);
1071
1150
  evaluateDirty(loggedInSet);
1072
1151
  await fetchDue(authStorage, directFetchers, getApiKey);
@@ -1145,7 +1224,7 @@ export async function startSharedPoller(
1145
1224
  const fresh = [...lastFetchedByProvider.values()].some((t) => Date.now() - t < idleDirtyMs);
1146
1225
  if (fresh) return;
1147
1226
 
1148
- evaluateDirty(loggedInSet);
1227
+ evaluateDirty(computeLoggedIn());
1149
1228
  await fetchDue(authStorage, directFetchers, getApiKey);
1150
1229
  })();
1151
1230
  try {
@@ -1156,7 +1235,11 @@ export async function startSharedPoller(
1156
1235
  }
1157
1236
 
1158
1237
  export function getSharedUsage(): SharedUsageState | null {
1159
- syncFromDisk();
1238
+ // Filter the on-disk union through THIS broker's most recently computed
1239
+ // logged-in set so providers owned by other brokers never surface as
1240
+ // columns/placeholders here (their reports stay on disk). Null (poller
1241
+ // never started / direct test calls) → unfiltered, legacy behavior.
1242
+ syncFromDisk(lastLoggedInSet ?? undefined);
1160
1243
  // Return state once we have data OR an in-flight fetch (so the UI can show
1161
1244
  // a loading hint even before the first report lands).
1162
1245
  if (cachedReports.length === 0 && lastFetchedByProvider.size === 0 && pendingProviders.size === 0) return null;
@@ -1360,9 +1443,11 @@ export function _resetForTest(): void {
1360
1443
  pendingProviders.clear();
1361
1444
  unconfirmedProviders.clear();
1362
1445
  confirmedThisProcess.clear();
1446
+ confirmedAtByProvider.clear();
1363
1447
  directFetcherIds.clear();
1364
1448
  cachedReports = [];
1365
1449
  cachedHealth = new Map();
1450
+ lastLoggedInSet = null;
1366
1451
  lastFlushedBySession.clear();
1367
1452
  lastSyncedTotals = new Map();
1368
1453
  perSecondReadCache = null;
@@ -1,5 +1,5 @@
1
- import { visibleWidth, truncateToWidth } from '@oh-my-pi/pi-tui';
2
1
  import { resolveUsedFraction, getProviderDefinition, type UsageLimit, type UsageReport } from '@oh-my-pi/pi-ai';
2
+ import { visibleWidth } from '@oh-my-pi/pi-tui';
3
3
  import { summarizeReport, quotaPolicyFor } from './usage-resolver.js';
4
4
 
5
5
  // Hardcoded ANSI retained for `ansiPainter` — the RPC/print fallback path and
@@ -29,8 +29,8 @@ export interface PainterTheme {
29
29
  }
30
30
 
31
31
  /**
32
- * Decouples "which ANSI/theme color wraps a span" from the validated flex-wrap
33
- * layout brain. `ansiPainter` reproduces the pre-refactor hardcoded ANSI; a
32
+ * Decouples "which ANSI/theme color wraps a span" from the validated single-row
33
+ * natural-width layout kernel. `ansiPainter` reproduces the pre-refactor hardcoded ANSI; a
34
34
  * `themePainter(theme)` routes every status color through the host theme so the
35
35
  * widget follows the active palette. Provider brand identity colors stay raw
36
36
  * ANSI under both painters (the host theme exposes no arbitrary-hue token — see
@@ -267,13 +267,6 @@ const windowSortKey = (l: UsageLimit): number => {
267
267
  return 50;
268
268
  };
269
269
 
270
- /** Right-align a cell: pad short lines; leave ≥-width lines intact (colWidth = max). */
271
- const padRight = (s: string, width: number): string => {
272
- const v = visibleWidth(s);
273
- if (v >= width) return s;
274
- return s + ' '.repeat(width - v);
275
- };
276
-
277
270
  // ── column builder ───────────────────────────────────────────────────
278
271
 
279
272
  /**
@@ -290,9 +283,9 @@ const padRight = (s: string, width: number): string => {
290
283
  * soonest reset countdown (any revived bucket unblocks).
291
284
  * - balance (DeepSeek): remaining ≤ 0 → countdown / `耗尽`
292
285
  *
293
- * `undefined` when there is nothing to show. Natural widths only —
294
- * `renderUsageReports` owns multi-column alignment, `ProviderCard` owns
295
- * per-card truncation.
286
+ * `undefined` when there is nothing to show. Emits natural-width lines only —
287
+ * `renderUsageReports` joins blocks into the single-row layout, `ProviderCard`
288
+ * owns per-card truncation.
296
289
  */
297
290
  export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, loading = false): string[] | undefined => {
298
291
  if (r.limits.length === 0) return;
@@ -384,9 +377,13 @@ export const buildPlaceholderColumn = (
384
377
  ];
385
378
  };
386
379
 
387
- // ── flex-wrap table layout ───────────────────────────────────────────
380
+ // ── single-row table layout ───────────────────────────────────────────
388
381
 
389
- const SEP_VISUAL = 3;
382
+ /** Right-pad a painted line to `width` visible cells (ANSI-safe via visibleWidth). */
383
+ const padRight = (s: string, width: number): string => {
384
+ const v = visibleWidth(s);
385
+ return v >= width ? s : s + ' '.repeat(width - v);
386
+ };
390
387
 
391
388
  export function renderUsageReports(
392
389
  reports: UsageReport[],
@@ -406,62 +403,70 @@ export function renderUsageReports(
406
403
  if (!reports?.length && placeholders.length === 0) return [];
407
404
  const loading = loadingProviders ?? new Set<string>();
408
405
 
409
- // Build each provider column (header + usage / placeholder). Waveforms are
410
- // appended AFTER the uniform block width is known so every provider's
411
- // mini chart is the same width.
406
+ // Build each provider column (header + usage / placeholder). Each block
407
+ // aligns its OWN lines to its own natural width (header/body/waveform
408
+ // share that block's width so the separators stack vertically) — blocks
409
+ // are never padded to a cross-block shared width, so one block's content
410
+ // changes never resize the others.
412
411
  interface ProviderColumn {
413
412
  provider: string;
414
413
  col: string[];
414
+ width: number;
415
415
  }
416
416
  const cols: ProviderColumn[] = (reports ?? []).map((r) => {
417
417
  const col =
418
418
  r.limits.length === 0
419
419
  ? buildEmptyReportColumn(r.provider, painter, loading.has(r.provider))
420
420
  : buildColumn(r, painter, loading.has(r.provider));
421
- if (!col) return { provider: r.provider, col: [] };
421
+ if (!col) return { provider: r.provider, col: [], width: 0 };
422
422
  // Second-level estimate override replaces the usage line (balance style).
423
423
  const est = usageEstimates?.get(r.provider);
424
424
  if (est && col.length >= 2) col[1] = painter.balance(est);
425
- return { provider: r.provider, col };
425
+ return { provider: r.provider, col, width: 0 };
426
426
  });
427
427
  for (const id of placeholders) {
428
428
  cols.push({
429
429
  provider: id,
430
430
  col: buildPlaceholderColumn(id, painter, loading.has(id), exhaustedProviders?.has(id)),
431
+ width: 0,
431
432
  });
432
433
  }
433
434
  const valid = cols.filter((c) => c.col.length > 0);
434
435
  if (valid.length === 0) return [];
435
436
 
436
- // Uniform block width: every provider block is padded to the widest column,
437
- // so usage columns align in a clean grid (no ragged per-provider widths).
438
- const natural = valid.map((c) => Math.max(...c.col.map((l) => visibleWidth(l))));
439
- const blockWidth = Math.max(...natural);
440
- const waveCols = Math.min(WAVEFORM_COLS, blockWidth);
441
-
442
- // Multiple providers: pad every row to the uniform block width so the grid
443
- // aligns (a lone provider keeps its natural width — nothing to align with).
444
- const pad = valid.length > 1;
445
437
  for (const c of valid) {
446
- // Equal-width per-provider consumption waveform (same char count for
447
- // all). A track that exists but has no samples renders as a blank
448
- // placeholder strip, keeping the column height stable.
438
+ // Per-provider consumption waveform at the FIXED WAVEFORM_COLS width:
439
+ // every chart renders 20 Braille columns regardless of the block's
440
+ // text width (no cross-block alignment). A track that exists but
441
+ // has no samples renders as a blank placeholder strip, keeping the
442
+ // block height stable.
449
443
  if (consumptionTracks) {
450
444
  const track = consumptionTracks.get(c.provider);
451
- if (track) c.col.push(...renderConsumptionLines(track.samples, painter, waveCols, providerLabel(c.provider).color));
445
+ if (track) c.col.push(...renderConsumptionLines(track.samples, painter, WAVEFORM_COLS, providerLabel(c.provider).color));
452
446
  }
453
- if (pad) c.col = c.col.map((l) => padRight(l, blockWidth));
447
+ // Per-block alignment: with multiple blocks, pad every line of THIS
448
+ // block to its own widest line (text or the fixed-width waveform) so
449
+ // the block's separators stack vertically across header/body/waveform
450
+ // rows. Blocks are NOT padded to each other — each keeps its own
451
+ // natural width, so one block's content changes never resize others.
452
+ // A single block has no separators to align, so it stays byte-exact
453
+ // (RPC/print baseline).
454
+ c.width = Math.max(...c.col.map((l) => visibleWidth(l)));
455
+ if (valid.length > 1) c.col = c.col.map((l) => padRight(l, c.width));
454
456
  }
455
457
  // Single row, never wrapped: every provider block sits on one line at its
456
- // natural uniform width — the row extends beyond `maxWidth` rather than
457
- // squeezing content or breaking to a second line (user preference:
458
- // roomy over compact).
458
+ // own natural width, joined by the fixed `painter.sep` — the row extends
459
+ // beyond `maxWidth` rather than squeezing content or breaking to a second
460
+ // line (user preference: roomy over compact). Blocks shorter than the
461
+ // tallest contribute a cell padded to that block's own width on missing
462
+ // rows, so every separator keeps its column.
463
+ const multi = valid.length > 1;
459
464
  const n = Math.max(...valid.map((c) => c.col.length));
460
465
  const out: string[] = [];
461
466
  for (let line = 0; line < n; line++) {
462
467
  const cells = valid.map((c) => {
463
- const t = line < c.col.length ? c.col[line] : '';
464
- return pad ? padRight(t, blockWidth) : t;
468
+ if (line < c.col.length) return c.col[line];
469
+ return multi ? ' '.repeat(c.width) : '';
465
470
  });
466
471
  out.push(cells.join(painter.sep));
467
472
  }
@@ -9,10 +9,11 @@ import { buildColumn, renderUsageReports, themePainter, type Painter, type Consu
9
9
  * `render(width)` returns the compact 2-line column (header + horizontal
10
10
  * window chips / reset countdown), each clamped to ≤ `width` via
11
11
  * `truncateToWidth`. Today the host drives the whole status strip through
12
- * `UsageTable`, which reuses the shared `renderUsageReports` flex-wrap kernel
13
- * rather than assembling cards directly — but this seam lets a future
14
- * per-provider overlay (expand / detail / quick action) build on a unit that
15
- * already renders in isolation, without reweaving the render tree.
12
+ * `UsageTable`, which reuses the shared `renderUsageReports` single-row
13
+ * natural-width layout kernel rather than assembling cards directly — but
14
+ * this seam lets a future per-provider overlay (expand / detail / quick
15
+ * action) build on a unit that already renders in isolation, without
16
+ * reweaving the render tree.
16
17
  *
17
18
  * No keyboard interaction is implemented this round: `handleInput` / `dispose`
18
19
  * are kept as empty extension points per the design non-goal.
@@ -44,9 +45,10 @@ export class ProviderCard implements Component {
44
45
  * Root pi-tui `Component` the host mounts in its hook-widget tree. A single
45
46
  * `UsageTable` owns the whole multi-provider layout (header + window rows for
46
47
  * every report): the host only vertically stacks hook widgets, so per-provider
47
- * widgets would collapse into a vertical list and lose the compact flex-wrap
48
- * columns. The layout brain is the validated `renderUsageReports` kernel — the
49
- * same code the RPC/print `string[]` fallback runs — only the `Painter` differs.
48
+ * widgets would collapse into a vertical list and lose the compact
49
+ * single-row natural-width columns. The layout brain is the validated
50
+ * `renderUsageReports` kernel — the same code the RPC/print `string[]`
51
+ * fallback runs — only the `Painter` differs.
50
52
  */
51
53
  export class UsageTable implements Component {
52
54
  readonly #reports: UsageReport[];
@@ -99,11 +101,13 @@ export class UsageTable implements Component {
99
101
  * resolved lazily against the theme the host passes at mount time, so the widget
100
102
  * follows the active palette; a later poll/resize re-runs `setWidget` with the
101
103
  * current reports (mirroring the dashboard precedent), which re-injects the then-
102
- * current theme. Returns a `Container`-free `UsageTable` directly: the validated
103
- * layout already yields per-line `visibleWidth ≤ width`, and wrapping each row in
104
- * `pi-tui`'s `Text` would both drop the flex-wrap blank separators (Text renders
105
- * empty input as `[]`) and repad every line to the full width — losing byte
106
- * parity with the RPC/print path for zero structural benefit.
104
+ * current theme. Returns a `Container`-free `UsageTable` directly: rows extend
105
+ * at their natural width (the kernel never wraps — a row can run past `width`),
106
+ * and `ProviderCard.render`'s `truncateToWidth` is the only truncation seam.
107
+ * Wrapping each row in `pi-tui`'s `Text` would both drop the single-row
108
+ * natural-width blank separators (Text renders empty input as `[]`) and repad
109
+ * every line to the full width — losing byte parity with the RPC/print path
110
+ * for zero structural benefit.
107
111
  */
108
112
  export function createUsageWidget(
109
113
  reports: UsageReport[],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genee/omp-opsx-addon",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Pi Extension: OpenSpec workflow orchestration - coder/reviewer/planner agents, session title & progress",
6
6
  "main": "./index.ts",