@prjct.app/pi-team 0.5.3 → 0.5.5

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.5](https://github.com/prjct-app/pi-team/compare/v0.5.4...v0.5.5) (2026-09-10)
2
+
3
+ ### Performance Improvements
4
+
5
+ * carry the canonical payload serialization instead of recomputing it ([#24](https://github.com/prjct-app/pi-team/issues/24)) ([340c495](https://github.com/prjct-app/pi-team/commit/340c4954358e715c6aed5e37e9f0a78f33100877))
6
+
7
+ ## [0.5.4](https://github.com/prjct-app/pi-team/compare/v0.5.3...v0.5.4) (2026-09-10)
8
+
9
+ ### Performance Improvements
10
+
11
+ * 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))
12
+
1
13
  ## [0.5.3](https://github.com/prjct-app/pi-team/compare/v0.5.2...v0.5.3) (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.3",
3
+ "version": "0.5.5",
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 };
@@ -651,14 +669,12 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
651
669
  set(() => ({ outcome: 'interrupted', finalText: 'User took over the session. Subsequent output was not forwarded. Review before continuing.' }));
652
670
  }
653
671
  const { outcome, finalText, files } = get();
654
- const report: Result = { outcome, body: finalText.slice(0, 3000) || `Agent turn ${outcome}; no final text. Review the recipient session.`, files: [], tests: [] };
655
- for (const file of files) {
656
- if (report.files.length >= 50 || file.length > 4096 || Buffer.byteLength(JSON.stringify({ ...report, files: [...report.files, file] })) > 31000) {
657
- report.body += '\nFile list truncated; review the recipient session.';
658
- break;
659
- }
660
- report.files.push(file);
661
- }
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
+ };
662
678
  await box.complete(member, finished.id, report);
663
679
  set(() => ({ active: undefined, ...(outcome !== 'completed' ? { paused: true } : {}) }));
664
680
  if (get().leaving) { await detach(); return false; }
package/src/mailbox.ts CHANGED
@@ -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
  /**
@@ -141,15 +150,22 @@ export class Mailbox {
141
150
  for (const attempt of ATTEMPTS) {
142
151
  const record = await this.readState(team);
143
152
  const state = record.payload;
144
- const before = JSON.stringify(state);
153
+ // The parser already produced this for the content hash. A pre-envelope
154
+ // record carries none, so that path serializes as before. A mismatch
155
+ // could only ever cause one redundant publication, never a lost write:
156
+ // `before` comes from the pre-action object and `after` from the
157
+ // post-action one, so they cannot coincide by accident.
158
+ const before = record.payloadJson ?? JSON.stringify(state);
145
159
  const presence = await this.readPresence(team);
146
160
  const swept = state.members.filter(member => member.status !== 'offline' && !this.alive(member, presence));
147
161
  for (const member of swept) this.disconnect(state, member);
148
162
  const result = action(state);
149
- if (before === JSON.stringify(state)) return result;
163
+ const after = JSON.stringify(state);
164
+ if (before === after) return result;
150
165
  if (!Value.Check(StateSchema, state)) throw new Error('Invalid mailbox format; refusing to write');
151
166
  try {
152
- await publish(this.recordPath(team), record.revision, state, this.normalize(team), { maxBytes: MAX_BYTES });
167
+ await publish(this.recordPath(team), record.revision, state, this.normalize(team),
168
+ { maxBytes: MAX_BYTES, payloadJson: after });
153
169
  await Promise.all(swept.map(member => unlink(this.presencePath(team, member.alias)).catch(() => {})));
154
170
  return result;
155
171
  } catch (error) {
@@ -203,7 +219,7 @@ export class Mailbox {
203
219
 
204
220
  private async writePresence(member: Membership, status: 'idle' | 'busy' | 'paused'): Promise<void> {
205
221
  const text = JSON.stringify({ token: member.token, status, seen: Date.now() } satisfies Presence);
206
- await writeAtomic(this.presencePath(member.team, member.alias), text, 'light');
222
+ await writeAtomic(this.presencePath(member.team, member.alias), text, 'none');
207
223
  }
208
224
 
209
225
  /** Lock-free heartbeat: touches only this member's own presence file. */
@@ -214,7 +230,14 @@ export class Mailbox {
214
230
  await this.writePresence(member, status);
215
231
  }
216
232
 
217
- /** Lock-free consistent view of the record with presence-based statuses. */
233
+ /**
234
+ * Lock-free consistent view of the record with presence-based statuses.
235
+ *
236
+ * The cached read returns a shared record, and the message objects below are
237
+ * the record's own, not copies. Never mutate them: it would corrupt both the
238
+ * process-wide cache and the `payloadJson` taken alongside it. `mutate` is
239
+ * safe because it reads uncached.
240
+ */
218
241
  async snapshot(member: Membership): Promise<Snapshot> {
219
242
  const record = await this.readState(member.team, true);
220
243
  this.owner(record.payload, member);
package/src/store.ts CHANGED
@@ -14,11 +14,22 @@ import { dirname, join } from 'node:path';
14
14
  * hard-links its envelope into a bounded revisions/ history, which doubles as
15
15
  * recovery evidence for interrupted writes.
16
16
  */
17
- export type Record<T> = { revision: number; payload: T };
17
+ /**
18
+ * `payloadJson` is the canonical `JSON.stringify(payload)`, carried when the
19
+ * parser already had to compute it for the content hash. It is consistent by
20
+ * construction, never derived from the raw file text, and always optional: a
21
+ * pre-envelope record has none and callers fall back to serializing.
22
+ */
23
+ export type Record<T> = { revision: number; payload: T; payloadJson?: string };
18
24
  /** Parse raw file bytes into a record, throwing on corruption. Never deletes. */
19
25
  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';
26
+ /**
27
+ * 'full' fsyncs file and directory; 'light' fsyncs the file only; 'none'
28
+ * fsyncs nothing and relies on the atomic rename alone. Use 'none' only for
29
+ * records that are rewritten on a timer and safe to lose, such as presence:
30
+ * a lost write there makes a member look offline sooner, never alive longer.
31
+ */
32
+ export type Durability = 'full' | 'light' | 'none';
22
33
 
23
34
  const STALE_LOCK_MS = 10_000;
24
35
  const KEEP_REVISIONS = 32;
@@ -41,10 +52,11 @@ export function envelope<T>(raw: string): Record<T> {
41
52
  if (typeof parsed.revision !== 'number' || !Number.isSafeInteger(parsed.revision) || parsed.revision < 1) {
42
53
  throw Object.assign(new Error('Invalid record revision.'), { code: 'CORRUPT_RECORD' });
43
54
  }
44
- if (parsed.contentHash !== sha256(JSON.stringify(parsed.payload))) {
55
+ const payloadJson = JSON.stringify(parsed.payload);
56
+ if (parsed.contentHash !== sha256(payloadJson)) {
45
57
  throw Object.assign(new Error('Record hash mismatch; preserved for manual recovery.'), { code: 'CORRUPT_RECORD' });
46
58
  }
47
- return { revision: parsed.revision, payload: parsed.payload as T };
59
+ return { revision: parsed.revision, payload: parsed.payload as T, payloadJson };
48
60
  }
49
61
 
50
62
  function assertSafeFile(path: string, info: { isFile(): boolean; size: number; mode: number; uid: number }, maxBytes: number): void {
@@ -99,17 +111,22 @@ async function syncDirectory(path: string): Promise<void> {
99
111
 
100
112
  /** Atomic last-writer-wins write for records without revision history (presence). */
101
113
  export async function writeAtomic(path: string, text: string, durability: Durability = 'full'): Promise<void> {
114
+ // Always: the presence directory does not exist before its first write.
102
115
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
103
116
  const tmp = `${path}.${randomUUID()}.tmp`;
104
117
  const handle = await open(tmp, 'wx', 0o600);
105
118
  try {
106
119
  await handle.writeFile(text, 'utf8');
107
- await handle.sync();
120
+ if (durability !== 'none') await handle.sync();
108
121
  } finally { await handle.close(); }
109
122
  try {
110
123
  await rename(tmp, path);
111
124
  if (durability === 'full') await syncDirectory(path);
112
- } finally { await unlink(tmp).catch(() => {}); }
125
+ return;
126
+ } catch (error) {
127
+ await unlink(tmp).catch(() => {});
128
+ throw error;
129
+ }
113
130
  }
114
131
 
115
132
  const locked = () => Object.assign(new Error('Another writer holds this record.'), { code: 'RECORD_LOCKED' });
@@ -152,7 +169,7 @@ async function pruneRevisions(dir: string, latest: number): Promise<void> {
152
169
  */
153
170
  export async function publish<T>(
154
171
  path: string, expectedRevision: number, payload: T, normalize: Normalize<T>,
155
- options: { maxBytes: number; durability?: Durability },
172
+ options: { maxBytes: number; durability?: Durability; payloadJson?: string },
156
173
  ): Promise<Record<T>> {
157
174
  const durability = options.durability ?? 'full';
158
175
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
@@ -165,12 +182,15 @@ export async function publish<T>(
165
182
  throw Object.assign(new Error(`Record changed before the write; current revision is ${revision}.`), { code: 'STALE_REVISION' });
166
183
  }
167
184
  const next = revision + 1;
168
- const payloadJson = JSON.stringify(payload);
185
+ // Supplied by callers that already serialized this exact object; it must
186
+ // equal JSON.stringify(payload). The record stays self-consistent either
187
+ // way, because the hash is taken over the string that gets embedded.
188
+ const payloadJson = options.payloadJson ?? JSON.stringify(payload);
169
189
  const text = `{"schemaVersion":1,"revision":${next},"contentHash":"${sha256(payloadJson)}","payload":${payloadJson}}`;
170
190
  if (Buffer.byteLength(text) > options.maxBytes) throw new Error('Record size limit exceeded.');
171
191
  const historyPath = join(dirname(path), 'revisions', `${next}.json`);
172
192
  const previous = await readRecord(historyPath, (raw: string) => envelope<T>(raw), options.maxBytes);
173
- if (previous && (previous.revision !== next || sha256(JSON.stringify(previous.payload)) !== sha256(payloadJson))) {
193
+ if (previous && (previous.revision !== next || (previous.payloadJson ?? JSON.stringify(previous.payload)) !== payloadJson)) {
174
194
  throw new Error('An interrupted publication owns this revision; explicit recovery is required.');
175
195
  }
176
196
  if (!previous) await writeAtomic(historyPath, text, durability);
@@ -185,7 +205,7 @@ export async function publish<T>(
185
205
  cache.delete(path);
186
206
  counters.publishes++;
187
207
  await pruneRevisions(dirname(path), next).catch(() => {});
188
- return { revision: next, payload };
208
+ return { revision: next, payload, payloadJson };
189
209
  } finally {
190
210
  await lock.close();
191
211
  await unlink(lockPath).catch(() => {});