@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 +12 -0
- package/package.json +1 -1
- package/src/index.ts +53 -20
- package/src/mailbox.ts +21 -3
- package/src/store.ts +23 -4
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
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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, '
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
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 {
|