@prjct.app/pi-team 0.5.2 → 0.5.3

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.5.3](https://github.com/prjct-app/pi-team/compare/v0.5.2...v0.5.3) (2026-09-10)
2
+
3
+ ### Performance Improvements
4
+
5
+ * stop opening a mailbox transaction on every idle tick ([#22](https://github.com/prjct-app/pi-team/issues/22)) ([561001f](https://github.com/prjct-app/pi-team/commit/561001f1fe8bb3048c005d74ef45d97e75b7ec09))
6
+
1
7
  ## [0.5.2](https://github.com/prjct-app/pi-team/compare/v0.5.1...v0.5.2) (2026-09-10)
2
8
 
3
9
  ### Performance Improvements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-team",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Coordinate independent PI Agent sessions with local team messaging, queued tasks, and shared results.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -140,6 +140,8 @@ type Session = Readonly<{
140
140
  lastReview: number;
141
141
  lastRevision: number;
142
142
  quietReviews: number;
143
+ widgetText?: string;
144
+ widgetCtx?: ExtensionContext;
143
145
  }>;
144
146
 
145
147
  const INITIAL: Session = {
@@ -182,6 +184,20 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
182
184
  needsCompaction, compactionSubject: needsCompaction ? compactionSubject : undefined,
183
185
  } : null);
184
186
  }
187
+ /**
188
+ * The widget is rebuilt on every tick otherwise. Keyed on context identity
189
+ * as well as text: `ctx` is replaced on session start and by the command
190
+ * handler, and a new context needs its own registration.
191
+ */
192
+ function showWidget(text: string | undefined) {
193
+ const { ctx, widgetText, widgetCtx } = get();
194
+ if (text === widgetText && ctx === widgetCtx) return;
195
+ set(() => ({ widgetText: text, widgetCtx: ctx }));
196
+ ctx?.ui.setWidget('team', text === undefined ? undefined : () => ({
197
+ invalidate() {},
198
+ render(width: number) { return [truncateToWidth(text, width)]; },
199
+ }));
200
+ }
185
201
  function stop() {
186
202
  const { timer, watcher } = get();
187
203
  if (timer) clearInterval(timer);
@@ -195,7 +211,7 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
195
211
  needsCompaction: false, compactionSubject: '', compactionGeneration: session.compactionGeneration + 1,
196
212
  }));
197
213
  persist();
198
- get().ctx?.ui.setWidget('team', undefined);
214
+ showWidget(undefined);
199
215
  }
200
216
  async function detach() {
201
217
  stop();
@@ -288,19 +304,16 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
288
304
  }
289
305
  const snap = await box.snapshot(member);
290
306
  set(() => ({ aliases: snap.members.map(m => m.alias) }));
291
- const pending = snap.messages.filter(m => m.to === member.alias && m.state === 'pending').length;
307
+ const inbox = snap.messages.filter(m => m.to === member.alias && m.state === 'pending');
308
+ const pending = inbox.length;
292
309
  const { compacting, needsCompaction, paused, active } = get();
293
310
  const status = `${member.team} · ${member.alias} · ${compacting || needsCompaction ? 'compacting' : paused ? 'paused' : !ctx.model ? 'select a model' : active ? 'working' : 'connected'}${pending ? ` · ${pending} pending` : ''}`;
294
- ctx.ui.setWidget('team', () => ({
295
- invalidate() {},
296
- render(width: number) { return [truncateToWidth(status, width)]; },
297
- }));
311
+ showWidget(status);
298
312
  if (get().leaving) return;
299
313
  // A disconnected peer holding a claim must be interrupted so its
300
- // requester receives a result instead of waiting forever.
301
- if (snap.messages.some(m => m.state === 'processing') && snap.members.some(m => m.status === 'offline')) {
302
- await box.sweep(member);
303
- }
314
+ // requester receives a result instead of waiting forever. Sweeping is a
315
+ // full mailbox transaction, so it runs only when it would change something.
316
+ if (snap.sweepable) await box.sweep(member);
304
317
  // Keep the session branch stable while Pi summarizes it. Team commands stay
305
318
  // registered, but no new peer content is appended or claimed until callback.
306
319
  if (get().compacting) return;
@@ -308,7 +321,11 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
308
321
  compactPendingContext(ctx);
309
322
  return;
310
323
  }
311
- for (const message of await box.notes(member)) pi.appendEntry('team-event', message);
324
+ // Consuming notes is a mailbox transaction too. The snapshot already lists
325
+ // every message addressed to this member, so it decides whether to open one.
326
+ if (inbox.some(m => m.kind === 'note')) {
327
+ for (const message of await box.notes(member)) pi.appendEntry('team-event', message);
328
+ }
312
329
  if (!ready()) return;
313
330
  if (get().budget >= 5) {
314
331
  if (pending) {
@@ -670,7 +687,7 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
670
687
  await box.leave(member).catch(notice);
671
688
  }
672
689
  set(() => ({ member: undefined, active: undefined }));
673
- get().ctx?.ui.setWidget('team', undefined);
690
+ showWidget(undefined);
674
691
  });
675
692
  });
676
693
  }
package/src/mailbox.ts CHANGED
@@ -15,7 +15,7 @@ export type Message = {
15
15
  };
16
16
  export type Outgoing = { to: string; kind: 'request' | 'note'; subject: string; body: string; parentId?: string };
17
17
  export type FlowItem = Pick<Message, 'id' | 'from' | 'to' | 'subject' | 'state' | 'created'>;
18
- export type Snapshot = { revision: number; members: Member[]; messages: Message[]; flow: FlowItem[] };
18
+ export type Snapshot = { revision: number; sweepable: boolean; members: Member[]; messages: Message[]; flow: FlowItem[] };
19
19
  type State = { version: 1; members: Member[]; messages: Message[] };
20
20
  type Presence = { token: string; status: 'idle' | 'busy' | 'paused'; seen: number };
21
21
  export const LEASE_MS = 30_000;
@@ -219,8 +219,17 @@ export class Mailbox {
219
219
  const record = await this.readState(member.team, true);
220
220
  this.owner(record.payload, member);
221
221
  const presence = await this.readPresence(member.team);
222
+ // Members the record still counts as connected but whose session is gone.
223
+ // Collected here so liveness is probed once per member, not twice.
224
+ const stale = record.payload.members.filter(m => m.status !== 'offline' && !this.alive(m, presence));
222
225
  return {
223
226
  revision: record.revision,
227
+ // A sweep only has an observable effect when a dead member still holds a
228
+ // claim: `disconnect` interrupts it so its requester gets a result.
229
+ // Members are never removed from the record, so "someone is offline" is
230
+ // permanently true once anyone leaves and cannot gate the sweep.
231
+ sweepable: stale.some(dead => record.payload.messages.some(
232
+ m => m.state === 'processing' && m.claim === dead.token && m.to === dead.alias)),
224
233
  members: record.payload.members.map(m => this.withStatus(m, presence)),
225
234
  messages: record.payload.messages.filter(m => m.from === member.alias || m.to === member.alias),
226
235
  // Expose only the metadata needed to understand team-wide request flow;
package/src/store.ts CHANGED
@@ -25,6 +25,13 @@ const KEEP_REVISIONS = 32;
25
25
 
26
26
  export const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
27
27
 
28
+ /**
29
+ * Diagnostics for the polling loop. `reads` counts full record parses, which
30
+ * happen once per attempted mutation and on a cache miss, never on a cache hit.
31
+ * A joined but idle session should leave both of these flat.
32
+ */
33
+ export const counters = { reads: 0, publishes: 0 };
34
+
28
35
  /** Standard envelope parser: schema marker, revision, and content hash. */
29
36
  export function envelope<T>(raw: string): Record<T> {
30
37
  const parsed = JSON.parse(raw) as { schemaVersion?: unknown; revision?: unknown; contentHash?: unknown; payload?: unknown };
@@ -62,6 +69,7 @@ export async function readRecord<T>(path: string, normalize: Normalize<T>, maxBy
62
69
  if (!handle) return undefined;
63
70
  try {
64
71
  assertSafeFile(path, await handle.stat(), maxBytes);
72
+ counters.reads++;
65
73
  return normalize(await handle.readFile('utf8'));
66
74
  } finally { await handle.close(); }
67
75
  }
@@ -175,6 +183,7 @@ export async function publish<T>(
175
183
  if (durability === 'full') await syncDirectory(path);
176
184
  } finally { await unlink(tmp).catch(() => {}); }
177
185
  cache.delete(path);
186
+ counters.publishes++;
178
187
  await pruneRevisions(dirname(path), next).catch(() => {});
179
188
  return { revision: next, payload };
180
189
  } finally {