@prjct.app/pi-team 0.5.3 → 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,9 @@
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
+
1
7
  ## [0.5.3](https://github.com/prjct-app/pi-team/compare/v0.5.2...v0.5.3) (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.3",
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 };
@@ -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
  /**
@@ -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. */
package/src/store.ts CHANGED
@@ -17,8 +17,13 @@ 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;
@@ -99,17 +104,22 @@ async function syncDirectory(path: string): Promise<void> {
99
104
 
100
105
  /** Atomic last-writer-wins write for records without revision history (presence). */
101
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.
102
108
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
103
109
  const tmp = `${path}.${randomUUID()}.tmp`;
104
110
  const handle = await open(tmp, 'wx', 0o600);
105
111
  try {
106
112
  await handle.writeFile(text, 'utf8');
107
- await handle.sync();
113
+ if (durability !== 'none') await handle.sync();
108
114
  } finally { await handle.close(); }
109
115
  try {
110
116
  await rename(tmp, path);
111
117
  if (durability === 'full') await syncDirectory(path);
112
- } finally { await unlink(tmp).catch(() => {}); }
118
+ return;
119
+ } catch (error) {
120
+ await unlink(tmp).catch(() => {});
121
+ throw error;
122
+ }
113
123
  }
114
124
 
115
125
  const locked = () => Object.assign(new Error('Another writer holds this record.'), { code: 'RECORD_LOCKED' });