@prjct.app/pi-team 0.5.2 → 0.5.4

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,15 @@
1
+ ## [0.5.4](https://github.com/prjct-app/pi-team/compare/v0.5.3...v0.5.4) (2026-09-10)
2
+
3
+ ### Performance Improvements
4
+
5
+ * drop the presence fsync, the quadratic file cap and a repeated mkdir ([#23](https://github.com/prjct-app/pi-team/issues/23)) ([75e5849](https://github.com/prjct-app/pi-team/commit/75e584965bd861cea96942497618f8c261c64d8d))
6
+
7
+ ## [0.5.3](https://github.com/prjct-app/pi-team/compare/v0.5.2...v0.5.3) (2026-09-10)
8
+
9
+ ### Performance Improvements
10
+
11
+ * 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))
12
+
1
13
  ## [0.5.2](https://github.com/prjct-app/pi-team/compare/v0.5.1...v0.5.2) (2026-09-10)
2
14
 
3
15
  ### 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.4",
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
@@ -96,6 +96,24 @@ function excerptPath(path: string, limit: number): string {
96
96
  return path.length <= limit ? path : `…${path.slice(-limit)}`;
97
97
  }
98
98
 
99
+ /**
100
+ * Fill a result's file list up to the serialized size cap. Each accepted path
101
+ * grows the encoded report by exactly its own encoding plus a separating
102
+ * comma, so a running total lands on the same boundary as re-serializing the
103
+ * whole report once per candidate, without the quadratic cost.
104
+ */
105
+ export function fitFiles(base: Result, candidates: Iterable<string>): { files: string[]; truncated: boolean } {
106
+ const files: string[] = [];
107
+ const size = { bytes: Buffer.byteLength(JSON.stringify({ ...base, files: [] })) };
108
+ for (const file of candidates) {
109
+ const addition = Buffer.byteLength(JSON.stringify(file)) + (files.length ? 1 : 0);
110
+ if (files.length >= 50 || file.length > 4096 || size.bytes + addition > 31000) return { files, truncated: true };
111
+ files.push(file);
112
+ size.bytes += addition;
113
+ }
114
+ return { files, truncated: false };
115
+ }
116
+
99
117
  /** Bound a list injected into the prompt, reporting what was left out. */
100
118
  function bounded<T>(items: T[], limit = STATUS_ITEMS): { items: T[]; omitted?: number } {
101
119
  return items.length <= limit ? { items } : { items: items.slice(0, limit), omitted: items.length - limit };
@@ -140,6 +158,8 @@ type Session = Readonly<{
140
158
  lastReview: number;
141
159
  lastRevision: number;
142
160
  quietReviews: number;
161
+ widgetText?: string;
162
+ widgetCtx?: ExtensionContext;
143
163
  }>;
144
164
 
145
165
  const INITIAL: Session = {
@@ -182,6 +202,20 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
182
202
  needsCompaction, compactionSubject: needsCompaction ? compactionSubject : undefined,
183
203
  } : null);
184
204
  }
205
+ /**
206
+ * The widget is rebuilt on every tick otherwise. Keyed on context identity
207
+ * as well as text: `ctx` is replaced on session start and by the command
208
+ * handler, and a new context needs its own registration.
209
+ */
210
+ function showWidget(text: string | undefined) {
211
+ const { ctx, widgetText, widgetCtx } = get();
212
+ if (text === widgetText && ctx === widgetCtx) return;
213
+ set(() => ({ widgetText: text, widgetCtx: ctx }));
214
+ ctx?.ui.setWidget('team', text === undefined ? undefined : () => ({
215
+ invalidate() {},
216
+ render(width: number) { return [truncateToWidth(text, width)]; },
217
+ }));
218
+ }
185
219
  function stop() {
186
220
  const { timer, watcher } = get();
187
221
  if (timer) clearInterval(timer);
@@ -195,7 +229,7 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
195
229
  needsCompaction: false, compactionSubject: '', compactionGeneration: session.compactionGeneration + 1,
196
230
  }));
197
231
  persist();
198
- get().ctx?.ui.setWidget('team', undefined);
232
+ showWidget(undefined);
199
233
  }
200
234
  async function detach() {
201
235
  stop();
@@ -288,19 +322,16 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
288
322
  }
289
323
  const snap = await box.snapshot(member);
290
324
  set(() => ({ aliases: snap.members.map(m => m.alias) }));
291
- const pending = snap.messages.filter(m => m.to === member.alias && m.state === 'pending').length;
325
+ const inbox = snap.messages.filter(m => m.to === member.alias && m.state === 'pending');
326
+ const pending = inbox.length;
292
327
  const { compacting, needsCompaction, paused, active } = get();
293
328
  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
- }));
329
+ showWidget(status);
298
330
  if (get().leaving) return;
299
331
  // 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
- }
332
+ // requester receives a result instead of waiting forever. Sweeping is a
333
+ // full mailbox transaction, so it runs only when it would change something.
334
+ if (snap.sweepable) await box.sweep(member);
304
335
  // Keep the session branch stable while Pi summarizes it. Team commands stay
305
336
  // registered, but no new peer content is appended or claimed until callback.
306
337
  if (get().compacting) return;
@@ -308,7 +339,11 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
308
339
  compactPendingContext(ctx);
309
340
  return;
310
341
  }
311
- for (const message of await box.notes(member)) pi.appendEntry('team-event', message);
342
+ // Consuming notes is a mailbox transaction too. The snapshot already lists
343
+ // every message addressed to this member, so it decides whether to open one.
344
+ if (inbox.some(m => m.kind === 'note')) {
345
+ for (const message of await box.notes(member)) pi.appendEntry('team-event', message);
346
+ }
312
347
  if (!ready()) return;
313
348
  if (get().budget >= 5) {
314
349
  if (pending) {
@@ -634,14 +669,12 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
634
669
  set(() => ({ outcome: 'interrupted', finalText: 'User took over the session. Subsequent output was not forwarded. Review before continuing.' }));
635
670
  }
636
671
  const { outcome, finalText, files } = get();
637
- const report: Result = { outcome, body: finalText.slice(0, 3000) || `Agent turn ${outcome}; no final text. Review the recipient session.`, files: [], tests: [] };
638
- for (const file of files) {
639
- if (report.files.length >= 50 || file.length > 4096 || Buffer.byteLength(JSON.stringify({ ...report, files: [...report.files, file] })) > 31000) {
640
- report.body += '\nFile list truncated; review the recipient session.';
641
- break;
642
- }
643
- report.files.push(file);
644
- }
672
+ const body = finalText.slice(0, 3000) || `Agent turn ${outcome}; no final text. Review the recipient session.`;
673
+ const fitted = fitFiles({ outcome, body, files: [], tests: [] }, files);
674
+ const report: Result = {
675
+ outcome, files: fitted.files, tests: [],
676
+ body: fitted.truncated ? `${body}\nFile list truncated; review the recipient session.` : body,
677
+ };
645
678
  await box.complete(member, finished.id, report);
646
679
  set(() => ({ active: undefined, ...(outcome !== 'completed' ? { paused: true } : {}) }));
647
680
  if (get().leaving) { await detach(); return false; }
@@ -670,7 +703,7 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
670
703
  await box.leave(member).catch(notice);
671
704
  }
672
705
  set(() => ({ member: undefined, active: undefined }));
673
- get().ctx?.ui.setWidget('team', undefined);
706
+ showWidget(undefined);
674
707
  });
675
708
  });
676
709
  }
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;
@@ -36,13 +36,22 @@ export function identifier(value: string): string {
36
36
  return value;
37
37
  }
38
38
 
39
+ /**
40
+ * Directories known to have been created by this process. Only the `mkdir` is
41
+ * skipped: the `lstat` check runs every time, because detecting a directory
42
+ * swapped after the fact is the entire point of the check.
43
+ */
44
+ const created = new Set<string>();
45
+
39
46
  async function privateDirectory(path: string): Promise<void> {
40
- await mkdir(path, { mode: 0o700, recursive: true });
47
+ if (!created.has(path)) await mkdir(path, { mode: 0o700, recursive: true });
41
48
  const stat = await lstat(path);
42
49
  if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 ||
43
50
  (process.getuid && stat.uid !== process.getuid())) {
51
+ created.delete(path);
44
52
  throw new Error(`Unsafe directory: ${path}. Expected a private directory owned by this user.`);
45
53
  }
54
+ created.add(path);
46
55
  }
47
56
 
48
57
  /**
@@ -203,7 +212,7 @@ export class Mailbox {
203
212
 
204
213
  private async writePresence(member: Membership, status: 'idle' | 'busy' | 'paused'): Promise<void> {
205
214
  const text = JSON.stringify({ token: member.token, status, seen: Date.now() } satisfies Presence);
206
- await writeAtomic(this.presencePath(member.team, member.alias), text, 'light');
215
+ await writeAtomic(this.presencePath(member.team, member.alias), text, 'none');
207
216
  }
208
217
 
209
218
  /** Lock-free heartbeat: touches only this member's own presence file. */
@@ -219,8 +228,17 @@ export class Mailbox {
219
228
  const record = await this.readState(member.team, true);
220
229
  this.owner(record.payload, member);
221
230
  const presence = await this.readPresence(member.team);
231
+ // Members the record still counts as connected but whose session is gone.
232
+ // Collected here so liveness is probed once per member, not twice.
233
+ const stale = record.payload.members.filter(m => m.status !== 'offline' && !this.alive(m, presence));
222
234
  return {
223
235
  revision: record.revision,
236
+ // A sweep only has an observable effect when a dead member still holds a
237
+ // claim: `disconnect` interrupts it so its requester gets a result.
238
+ // Members are never removed from the record, so "someone is offline" is
239
+ // permanently true once anyone leaves and cannot gate the sweep.
240
+ sweepable: stale.some(dead => record.payload.messages.some(
241
+ m => m.state === 'processing' && m.claim === dead.token && m.to === dead.alias)),
224
242
  members: record.payload.members.map(m => this.withStatus(m, presence)),
225
243
  messages: record.payload.messages.filter(m => m.from === member.alias || m.to === member.alias),
226
244
  // Expose only the metadata needed to understand team-wide request flow;
package/src/store.ts CHANGED
@@ -17,14 +17,26 @@ import { dirname, join } from 'node:path';
17
17
  export type Record<T> = { revision: number; payload: T };
18
18
  /** Parse raw file bytes into a record, throwing on corruption. Never deletes. */
19
19
  export type Normalize<T> = (raw: string) => Record<T>;
20
- /** 'full' fsyncs file and directory; 'light' fsyncs the file only (presence). */
21
- export type Durability = 'full' | 'light';
20
+ /**
21
+ * 'full' fsyncs file and directory; 'light' fsyncs the file only; 'none'
22
+ * fsyncs nothing and relies on the atomic rename alone. Use 'none' only for
23
+ * records that are rewritten on a timer and safe to lose, such as presence:
24
+ * a lost write there makes a member look offline sooner, never alive longer.
25
+ */
26
+ export type Durability = 'full' | 'light' | 'none';
22
27
 
23
28
  const STALE_LOCK_MS = 10_000;
24
29
  const KEEP_REVISIONS = 32;
25
30
 
26
31
  export const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
27
32
 
33
+ /**
34
+ * Diagnostics for the polling loop. `reads` counts full record parses, which
35
+ * happen once per attempted mutation and on a cache miss, never on a cache hit.
36
+ * A joined but idle session should leave both of these flat.
37
+ */
38
+ export const counters = { reads: 0, publishes: 0 };
39
+
28
40
  /** Standard envelope parser: schema marker, revision, and content hash. */
29
41
  export function envelope<T>(raw: string): Record<T> {
30
42
  const parsed = JSON.parse(raw) as { schemaVersion?: unknown; revision?: unknown; contentHash?: unknown; payload?: unknown };
@@ -62,6 +74,7 @@ export async function readRecord<T>(path: string, normalize: Normalize<T>, maxBy
62
74
  if (!handle) return undefined;
63
75
  try {
64
76
  assertSafeFile(path, await handle.stat(), maxBytes);
77
+ counters.reads++;
65
78
  return normalize(await handle.readFile('utf8'));
66
79
  } finally { await handle.close(); }
67
80
  }
@@ -91,17 +104,22 @@ async function syncDirectory(path: string): Promise<void> {
91
104
 
92
105
  /** Atomic last-writer-wins write for records without revision history (presence). */
93
106
  export async function writeAtomic(path: string, text: string, durability: Durability = 'full'): Promise<void> {
107
+ // Always: the presence directory does not exist before its first write.
94
108
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
95
109
  const tmp = `${path}.${randomUUID()}.tmp`;
96
110
  const handle = await open(tmp, 'wx', 0o600);
97
111
  try {
98
112
  await handle.writeFile(text, 'utf8');
99
- await handle.sync();
113
+ if (durability !== 'none') await handle.sync();
100
114
  } finally { await handle.close(); }
101
115
  try {
102
116
  await rename(tmp, path);
103
117
  if (durability === 'full') await syncDirectory(path);
104
- } finally { await unlink(tmp).catch(() => {}); }
118
+ return;
119
+ } catch (error) {
120
+ await unlink(tmp).catch(() => {});
121
+ throw error;
122
+ }
105
123
  }
106
124
 
107
125
  const locked = () => Object.assign(new Error('Another writer holds this record.'), { code: 'RECORD_LOCKED' });
@@ -175,6 +193,7 @@ export async function publish<T>(
175
193
  if (durability === 'full') await syncDirectory(path);
176
194
  } finally { await unlink(tmp).catch(() => {}); }
177
195
  cache.delete(path);
196
+ counters.publishes++;
178
197
  await pruneRevisions(dirname(path), next).catch(() => {});
179
198
  return { revision: next, payload };
180
199
  } finally {