@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 +12 -0
- package/package.json +1 -1
- package/src/index.ts +24 -8
- package/src/mailbox.ts +29 -6
- package/src/store.ts +31 -11
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
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
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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, '
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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(() => {});
|