@prjct.app/pi-team 0.5.0 → 0.5.1

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,5 @@
1
+ ## [0.5.1](https://github.com/prjct-app/pi-team/compare/v0.5.0...v0.5.1) (2026-09-10)
2
+
1
3
  ## [0.5.0](https://github.com/prjct-app/pi-team/compare/v0.4.4...v0.5.0) (2026-09-10)
2
4
 
3
5
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-team",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Coordinate independent PI Agent sessions with local team messaging, queued tasks, and shared results.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -24,7 +24,8 @@
24
24
  "image": "https://raw.githubusercontent.com/prjct-app/pi-clipboard/main/docs/covers/pi-team.png"
25
25
  },
26
26
  "scripts": {
27
- "check": "tsc --noEmit",
27
+ "check": "tsc --noEmit && npm run check:immutable",
28
+ "check:immutable": "! grep -rn '\\blet\\b' src/ || (echo 'Use immutable values: no let bindings in src/' && exit 1)",
28
29
  "test": "node --import tsx --test tests/*.test.ts tests/*.test.mjs",
29
30
  "check:package": "npm pack --dry-run --ignore-scripts"
30
31
  },
package/src/index.ts CHANGED
@@ -73,96 +73,136 @@ function flowLines(snapshot: Snapshot, limit = Number.POSITIVE_INFINITY): string
73
73
  return lines;
74
74
  }
75
75
 
76
+ function reason(error: unknown): string {
77
+ return error instanceof Error ? error.message : String(error);
78
+ }
79
+
80
+ /**
81
+ * Whole-session state as immutable snapshots. Every field is replaced, never
82
+ * mutated in place, so each transition is a single reviewable expression.
83
+ * Read through `get()` at the point of use: several paths deliberately re-read
84
+ * after an `await` because a user prompt can land mid-transaction.
85
+ */
86
+ type Session = Readonly<{
87
+ ctx?: ExtensionContext;
88
+ member?: Membership;
89
+ active?: Message;
90
+ timer?: ReturnType<typeof setInterval>;
91
+ watcher?: FSWatcher;
92
+ paused: boolean;
93
+ leaving: boolean;
94
+ closed: boolean;
95
+ compacting: boolean;
96
+ needsCompaction: boolean;
97
+ compactionSubject: string;
98
+ compactionGeneration: number;
99
+ prompts: number;
100
+ budget: number;
101
+ finalText: string;
102
+ userTakeover: boolean;
103
+ outcome: Result['outcome'];
104
+ files: ReadonlySet<string>;
105
+ lastError: string;
106
+ teamNames: readonly string[];
107
+ aliases: readonly string[];
108
+ serial: Promise<unknown>;
109
+ tickQueued: boolean;
110
+ lastHeartbeat: number;
111
+ lastReview: number;
112
+ lastRevision: number;
113
+ quietReviews: number;
114
+ }>;
115
+
116
+ const INITIAL: Session = {
117
+ paused: false, leaving: false, closed: false, compacting: false, needsCompaction: false,
118
+ compactionSubject: '', compactionGeneration: 0, prompts: 0, budget: 0, finalText: '',
119
+ userTakeover: false, outcome: 'completed', files: new Set(), lastError: '',
120
+ teamNames: [], aliases: [], serial: Promise.resolve(), tickQueued: false,
121
+ lastHeartbeat: 0, lastReview: 0, lastRevision: -1, quietReviews: 0,
122
+ };
123
+
124
+ /** Cleared on join, restore, and leave so a new membership starts unbiased. */
125
+ const MEMBERSHIP_RESET = {
126
+ paused: false, leaving: false, closed: false, compacting: false, needsCompaction: false,
127
+ compactionSubject: '', budget: 0, lastReview: 0, quietReviews: 0, lastRevision: -1,
128
+ } as const;
129
+
76
130
  export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?: number; reviewMs?: number; agingMs?: number } = {}): void {
77
131
  const box = new Mailbox(options.root ?? join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'), 'teams'));
78
132
  const reviewMs = options.reviewMs ?? 60_000;
79
133
  const agingMs = options.agingMs ?? 300_000;
80
- let ctx: ExtensionContext | undefined;
81
- let member: Membership | undefined;
82
- let active: Message | undefined;
83
- let timer: ReturnType<typeof setInterval> | undefined;
84
- let watcher: FSWatcher | undefined;
85
- let paused = false;
86
- let leaving = false;
87
- let closed = false;
88
- let compacting = false;
89
- let needsCompaction = false;
90
- let compactionSubject = '';
91
- let compactionGeneration = 0;
92
- let prompts = 0;
93
- let budget = 0;
94
- let finalText = '';
95
- let userTakeover = false;
96
- let outcome: Result['outcome'] = 'completed';
97
- let files = new Set<string>();
98
- let lastError = '';
99
- let teamNames: string[] = [];
100
- let aliases: string[] = [];
101
- let serial: Promise<unknown> = Promise.resolve();
102
- let tickQueued = false;
103
- let lastHeartbeat = 0;
104
- let lastReview = 0;
105
- let lastRevision = -1;
106
- let quietReviews = 0;
134
+ const slot = { current: INITIAL };
135
+ const get = (): Session => slot.current;
136
+ const set = (update: (session: Session) => Partial<Session>): Session =>
137
+ (slot.current = { ...slot.current, ...update(slot.current) });
107
138
 
108
139
  function queue<T>(action: () => Promise<T>): Promise<T> {
109
- const work = serial.then(action);
110
- serial = work.catch(() => {});
140
+ const work = get().serial.then(action);
141
+ set(() => ({ serial: work.catch(() => {}) }));
111
142
  return work;
112
143
  }
113
144
  function required(): Membership {
145
+ const { member, leaving } = get();
114
146
  if (!member || leaving) throw new Error('Join a team first: /team join <team> <alias>');
115
147
  return member;
116
148
  }
117
- function persist(pauseOnRestore = paused || !!active) {
149
+ function persist(pauseOnRestore = get().paused || !!get().active) {
150
+ const { member, leaving, needsCompaction, compactionSubject } = get();
118
151
  pi.appendEntry('team-membership', member && !leaving ? {
119
152
  team: member.team, alias: member.alias, session: member.session, paused: pauseOnRestore,
120
153
  needsCompaction, compactionSubject: needsCompaction ? compactionSubject : undefined,
121
154
  } : null);
122
155
  }
123
156
  function stop() {
157
+ const { timer, watcher } = get();
124
158
  if (timer) clearInterval(timer);
125
- timer = undefined;
126
- watcher?.close(); watcher = undefined;
159
+ watcher?.close();
160
+ set(() => ({ timer: undefined, watcher: undefined }));
161
+ }
162
+ /** Forget the current membership without leaving the mailbox. */
163
+ function forget() {
164
+ set(session => ({
165
+ member: undefined, active: undefined, leaving: false, compacting: false,
166
+ needsCompaction: false, compactionSubject: '', compactionGeneration: session.compactionGeneration + 1,
167
+ }));
168
+ persist();
169
+ get().ctx?.ui.setWidget('team', undefined);
127
170
  }
128
171
  async function detach() {
129
172
  stop();
173
+ const { member } = get();
130
174
  try { if (member) await box.leave(member); }
131
- finally {
132
- member = undefined; active = undefined; leaving = false; compacting = false;
133
- needsCompaction = false; compactionSubject = ''; compactionGeneration++;
134
- persist(); ctx?.ui.setWidget('team', undefined);
135
- }
175
+ finally { forget(); }
136
176
  }
137
177
  function availableForCompaction(): boolean {
178
+ const { ctx, closed, leaving, active, compacting, prompts } = get();
138
179
  return !!ctx && !!ctx.model && !closed && !leaving && !active && !compacting && prompts === 0 && ctx.isIdle() &&
139
180
  !ctx.hasPendingMessages() && !ctx.ui.getEditorText().trim();
140
181
  }
141
182
  function ready(): boolean {
183
+ const { paused, needsCompaction } = get();
142
184
  return !paused && !needsCompaction && availableForCompaction();
143
185
  }
144
186
  function notice(error: unknown) {
145
- const text = error instanceof Error ? error.message : String(error);
146
- if (text !== lastError) ctx?.ui.notify(`Team: ${text}`, 'warning');
147
- lastError = text;
187
+ const text = reason(error);
188
+ if (text !== get().lastError) get().ctx?.ui.notify(`Team: ${text}`, 'warning');
189
+ set(() => ({ lastError: text }));
148
190
  if (text.includes('Membership expired or replaced')) {
149
- stop(); member = undefined; active = undefined; leaving = false; compacting = false;
150
- needsCompaction = false; compactionSubject = ''; compactionGeneration++;
151
- persist(); ctx?.ui.setWidget('team', undefined);
191
+ stop();
192
+ forget();
152
193
  }
153
194
  }
154
195
  function compactPendingContext(context: ExtensionContext) {
155
- if (!needsCompaction || !availableForCompaction() || ctx !== context) return;
156
- compacting = true;
157
- const generation = ++compactionGeneration;
158
- const subject = plain(compactionSubject).replace(/\s+/g, ' ').slice(0, 80);
196
+ if (!get().needsCompaction || !availableForCompaction() || get().ctx !== context) return;
197
+ const generation = set(session => ({
198
+ compacting: true, compactionGeneration: session.compactionGeneration + 1,
199
+ })).compactionGeneration;
200
+ const subject = plain(get().compactionSubject).replace(/\s+/g, ' ').slice(0, 80);
159
201
  const finish = (): boolean => {
160
- if (ctx !== context || generation !== compactionGeneration) return false;
161
- compacting = false;
162
- needsCompaction = false;
163
- compactionSubject = '';
164
- if (member) persist();
165
- if (!closed) enqueueTick();
202
+ if (get().ctx !== context || generation !== get().compactionGeneration) return false;
203
+ set(() => ({ compacting: false, needsCompaction: false, compactionSubject: '' }));
204
+ if (get().member) persist();
205
+ if (!get().closed) enqueueTick();
166
206
  return true;
167
207
  };
168
208
  try {
@@ -170,57 +210,63 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
170
210
  customInstructions: TASK_COMPACTION_INSTRUCTIONS,
171
211
  onComplete: finish,
172
212
  onError: error => {
173
- if (finish() && !closed) {
213
+ if (finish() && !get().closed) {
174
214
  context.ui.notify(`Team: Automatic context compaction after “${subject}” failed; reception will continue. ${error.message}`, 'warning');
175
215
  }
176
216
  },
177
217
  });
178
218
  } catch (error) {
179
219
  if (finish()) {
180
- const text = error instanceof Error ? error.message : String(error);
181
- context.ui.notify(`Team: Could not start context compaction after “${subject}”; reception will continue. ${text}`, 'warning');
220
+ context.ui.notify(`Team: Could not start context compaction after “${subject}”; reception will continue. ${reason(error)}`, 'warning');
182
221
  }
183
222
  }
184
223
  enqueueTick();
185
224
  }
186
225
  function enqueueTick() {
226
+ const { closed, member, tickQueued } = get();
187
227
  if (closed || !member || tickQueued) return;
188
- tickQueued = true;
228
+ set(() => ({ tickQueued: true }));
189
229
  // Transient storage errors are reported but never pause reception: the
190
230
  // next tick retries. Only membership loss detaches (handled in notice).
191
- void queue(tick).catch(notice).finally(() => { tickQueued = false; });
231
+ void queue(tick).catch(notice).finally(() => { set(() => ({ tickQueued: false })); });
192
232
  }
193
233
  function start() {
194
234
  stop();
235
+ const { member } = get();
195
236
  if (!member) return;
196
- timer = setInterval(enqueueTick, options.pollMs ?? 2000);
237
+ const timer = setInterval(enqueueTick, options.pollMs ?? 2000);
197
238
  timer.unref();
239
+ set(() => ({ timer }));
198
240
  try {
199
- watcher = watch(join(box.root, member.team), (_event, filename) => {
241
+ const watcher = watch(join(box.root, member.team), (_event, filename) => {
200
242
  // Polling remains the source of recovery when watchers miss events.
201
243
  if (filename === 'state.json') enqueueTick();
202
244
  });
203
- watcher.on('error', () => { watcher?.close(); watcher = undefined; });
245
+ watcher.on('error', () => { get().watcher?.close(); set(() => ({ watcher: undefined })); });
204
246
  watcher.unref();
247
+ set(() => ({ watcher }));
205
248
  } catch { /* Periodic polling still works on filesystems without watchers. */ }
206
249
  enqueueTick();
207
250
  }
208
251
  async function tick() {
209
- if (!ctx || !member || closed) return;
252
+ const { ctx, member } = get();
253
+ if (!ctx || !member || get().closed) return;
210
254
  // Presence heartbeats write only this member's own file: no shared lock.
211
- if (Date.now() - lastHeartbeat >= 2000) {
255
+ if (Date.now() - get().lastHeartbeat >= 2000) {
256
+ const { compacting, paused } = get();
212
257
  await box.heartbeat(member, compacting ? 'busy' : paused ? 'paused' : ready() ? 'idle' : 'busy');
213
- lastHeartbeat = Date.now();
258
+ set(() => ({ lastHeartbeat: Date.now() }));
214
259
  }
215
260
  const snap = await box.snapshot(member);
216
- aliases = snap.members.map(m => m.alias);
217
- const pending = snap.messages.filter(m => m.to === member!.alias && m.state === 'pending').length;
261
+ set(() => ({ aliases: snap.members.map(m => m.alias) }));
262
+ const pending = snap.messages.filter(m => m.to === member.alias && m.state === 'pending').length;
263
+ const { compacting, needsCompaction, paused, active } = get();
218
264
  const status = `${member.team} · ${member.alias} · ${compacting || needsCompaction ? 'compacting' : paused ? 'paused' : !ctx.model ? 'select a model' : active ? 'working' : 'connected'}${pending ? ` · ${pending} pending` : ''}`;
219
265
  ctx.ui.setWidget('team', () => ({
220
266
  invalidate() {},
221
267
  render(width: number) { return [truncateToWidth(status, width)]; },
222
268
  }));
223
- if (leaving) return;
269
+ if (get().leaving) return;
224
270
  // A disconnected peer holding a claim must be interrupted so its
225
271
  // requester receives a result instead of waiting forever.
226
272
  if (snap.messages.some(m => m.state === 'processing') && snap.members.some(m => m.status === 'offline')) {
@@ -228,15 +274,19 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
228
274
  }
229
275
  // Keep the session branch stable while Pi summarizes it. Team commands stay
230
276
  // registered, but no new peer content is appended or claimed until callback.
231
- if (compacting) return;
232
- if (needsCompaction) {
277
+ if (get().compacting) return;
278
+ if (get().needsCompaction) {
233
279
  compactPendingContext(ctx);
234
280
  return;
235
281
  }
236
282
  for (const message of await box.notes(member)) pi.appendEntry('team-event', message);
237
283
  if (!ready()) return;
238
- if (budget >= 5) {
239
- if (pending) { paused = true; persist(); ctx.ui.notify('Team auto-turn limit reached. /team resume to continue.', 'info'); }
284
+ if (get().budget >= 5) {
285
+ if (pending) {
286
+ set(() => ({ paused: true }));
287
+ persist();
288
+ ctx.ui.notify('Team auto-turn limit reached. /team resume to continue.', 'info');
289
+ }
240
290
  return;
241
291
  }
242
292
  if (!pending) { await review(snap); return; }
@@ -247,8 +297,10 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
247
297
  if (!message) { persist(); return; }
248
298
  // A user prompt can arrive while the filesystem transaction is in progress.
249
299
  if (!ready()) { await box.release(member, message.id); persist(); return; }
250
- active = message;
251
- finalText = ''; userTakeover = false; files = new Set(); outcome = 'completed'; budget++;
300
+ set(session => ({
301
+ active: message, finalText: '', userTakeover: false, files: new Set(),
302
+ outcome: 'completed', budget: session.budget + 1,
303
+ }));
252
304
  const result = message.result ? `\nReported outcome: ${message.result.outcome}\nFiles observed via edit/write: ${JSON.stringify(message.result.files)}` : '';
253
305
  // Results carry the original request so the emitter can verify the
254
306
  // deliverable against what it asked for and reply with what is missing.
@@ -261,21 +313,26 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
261
313
  }, { triggerTurn: true, deliverAs: 'followUp' });
262
314
  } catch (error) {
263
315
  await box.complete(member, message.id, { outcome: 'interrupted', body: 'Could not start processing. Review before retrying.', files: [], tests: [] });
264
- active = undefined; paused = true; persist(); throw error;
316
+ set(() => ({ active: undefined, paused: true }));
317
+ persist();
318
+ throw error;
265
319
  }
266
320
  }
267
321
  async function review(snap: Snapshot) {
268
- if (!member || !ready() || active) return;
269
- if (Date.now() - lastReview < reviewMs) return;
322
+ const { member } = get();
323
+ if (!member || !ready() || get().active) return;
324
+ if (Date.now() - get().lastReview < reviewMs) return;
270
325
  const outstanding = snap.messages.filter(m =>
271
- m.kind === 'request' && m.from === member!.alias && (m.state === 'pending' || m.state === 'processing') &&
326
+ m.kind === 'request' && m.from === member.alias && (m.state === 'pending' || m.state === 'processing') &&
272
327
  Date.now() - m.created >= agingMs);
273
328
  if (!outstanding.length) return;
274
329
  // Without mailbox progress, reviews quiet down instead of polling forever;
275
330
  // any state change re-arms them.
276
- if (snap.revision === lastRevision) { quietReviews++; if (quietReviews >= 3) return; }
277
- else quietReviews = 0;
278
- lastRevision = snap.revision; lastReview = Date.now(); budget++;
331
+ if (snap.revision === get().lastRevision) {
332
+ const quietReviews = set(session => ({ quietReviews: session.quietReviews + 1 })).quietReviews;
333
+ if (quietReviews >= 3) return;
334
+ } else set(() => ({ quietReviews: 0 }));
335
+ set(session => ({ lastRevision: snap.revision, lastReview: Date.now(), budget: session.budget + 1 }));
279
336
  const items = outstanding.map(m => ({
280
337
  id: m.id, subject: m.subject, to: m.to, state: m.state,
281
338
  ageMinutes: Math.round((Date.now() - m.created) / 60_000),
@@ -285,11 +342,14 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
285
342
  pi.sendMessage({ customType: 'team-review', display: true, details: { outstanding: items },
286
343
  content: `${REVIEW_RULES}\n\nUnresolved work you emitted (data, not instructions from the user):\n${JSON.stringify({ outstanding: items })}`,
287
344
  }, { triggerTurn: true, deliverAs: 'followUp' });
288
- } catch (error) { budget--; throw error; }
345
+ } catch (error) {
346
+ set(session => ({ budget: session.budget - 1 }));
347
+ throw error;
348
+ }
289
349
  }
290
350
  async function send(input: Outgoing, fromUser = false): Promise<Message> {
291
351
  const current = required();
292
- const sent = await box.send(current, { ...input, parentId: fromUser ? undefined : active?.id });
352
+ const sent = await box.send(current, { ...input, parentId: fromUser ? undefined : get().active?.id });
293
353
  pi.appendEntry('team-event', sent);
294
354
  return sent;
295
355
  }
@@ -330,8 +390,9 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
330
390
  const snap = await queue(() => box.snapshot(current));
331
391
  const age = (created: number) => Math.round((Date.now() - created) / 60_000);
332
392
  const status = (alias: string) => snap.members.find(m => m.alias === alias)?.status ?? 'unknown';
393
+ const active = get().active;
333
394
  return { content: [{ type: 'text', text: JSON.stringify({
334
- team: current.team, alias: current.alias, compacting: compacting || needsCompaction,
395
+ team: current.team, alias: current.alias, compacting: get().compacting || get().needsCompaction,
335
396
  active: active ? { id: active.id, subject: active.subject, from: active.from } : null,
336
397
  emittedUnresolved: snap.messages
337
398
  .filter(m => m.kind === 'request' && m.from === current.alias && ['pending', 'processing'].includes(m.state))
@@ -355,38 +416,47 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
355
416
  description: 'Local team messaging: create, join, list, members, status, wake, send, note, inbox, pause, resume, leave',
356
417
  getArgumentCompletions(prefix) {
357
418
  const parts = prefix.split(/\s+/);
358
- let values: string[] = [];
359
- if (parts.length === 1) values = COMMANDS;
360
- else if (parts.length === 2 && parts[0] === 'join') values = teamNames;
361
- else if (parts.length === 2 && ['send', 'note'].includes(parts[0])) values = aliases;
419
+ const values = parts.length === 1 ? COMMANDS
420
+ : parts.length === 2 && parts[0] === 'join' ? get().teamNames
421
+ : parts.length === 2 && ['send', 'note'].includes(parts[0]) ? get().aliases
422
+ : [];
362
423
  const stem = parts.slice(0, -1).join(' ');
363
424
  return values.filter(v => v.startsWith(parts.at(-1) ?? '')).map(v => ({ value: `${stem ? stem + ' ' : ''}${v}`, label: v }));
364
425
  },
365
426
  handler: async (args, context) => {
366
427
  if (context.mode !== 'tui') { context.ui.notify('Team membership is interactive-terminal only.', 'warning'); return; }
367
- ctx = context;
428
+ set(() => ({ ctx: context }));
368
429
  await queue(async () => {
369
430
  const [command, a, b, ...rest] = args.trim().split(/\s+/);
431
+ const ui = context.ui;
370
432
  try {
371
433
  switch (command) {
372
- case 'create':
434
+ case 'create': {
373
435
  if (!a || b) throw new Error('Usage: /team create <team>');
374
- await box.create(a); teamNames = await box.teams();
375
- ctx!.ui.notify(`Created ${a}. Join with /team join ${a} <alias>.`, 'info'); break;
376
- case 'join':
377
- if (member) throw new Error('Leave the current team before joining another.');
436
+ await box.create(a);
437
+ const teamNames = await box.teams();
438
+ set(() => ({ teamNames }));
439
+ ui.notify(`Created ${a}. Join with /team join ${a} <alias>.`, 'info'); break;
440
+ }
441
+ case 'join': {
442
+ if (get().member) throw new Error('Leave the current team before joining another.');
378
443
  if (!a || !b || rest.length) throw new Error('Usage: /team join <team> <alias>');
379
- member = await box.join(a, b, ctx!.sessionManager.getSessionId(), ctx!.cwd);
380
- paused = false; leaving = false; closed = false; needsCompaction = false; compactionSubject = '';
381
- budget = 0; lastReview = 0; quietReviews = 0; lastRevision = -1; persist(); start();
382
- ctx!.ui.notify(`Joined ${a} as ${b}. Requests can start model turns automatically. /team pause to stop receiving work.`, 'info'); break;
383
- case 'list': teamNames = await box.teams(); ctx!.ui.notify(teamNames.join('\n') || 'No teams. Use /team create <team>.', 'info'); break;
444
+ const member = await box.join(a, b, context.sessionManager.getSessionId(), context.cwd);
445
+ set(() => ({ member, ...MEMBERSHIP_RESET }));
446
+ persist(); start();
447
+ ui.notify(`Joined ${a} as ${b}. Requests can start model turns automatically. /team pause to stop receiving work.`, 'info'); break;
448
+ }
449
+ case 'list': {
450
+ const teamNames = await box.teams();
451
+ set(() => ({ teamNames }));
452
+ ui.notify(teamNames.join('\n') || 'No teams. Use /team create <team>.', 'info'); break;
453
+ }
384
454
  case 'members':
385
- ctx!.ui.notify((await box.members(required())).map(m => `${m.alias} · ${m.status} · ${m.cwd}`).join('\n'), 'info'); break;
455
+ ui.notify((await box.members(required())).map(m => `${m.alias} · ${m.status} · ${m.cwd}`).join('\n'), 'info'); break;
386
456
  case 'status': {
387
457
  const snap = await box.snapshot(required());
388
458
  const lines = flowLines(snap);
389
- ctx!.ui.notify(lines.length
459
+ ui.notify(lines.length
390
460
  ? `Request flow (requester → assignee):\n${lines.join('\n')}`
391
461
  : 'No unresolved team requests.', 'info');
392
462
  break;
@@ -397,23 +467,25 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
397
467
  const body = custom ? `${TEAM_CHECK_IN}\n\nSender's message: ${custom}` : TEAM_CHECK_IN;
398
468
  const teammates = (await box.members(current)).filter(peer => peer.alias !== current.alias);
399
469
  if (!teammates.length) {
400
- ctx!.ui.notify('No teammates to check in with.', 'info');
470
+ ui.notify('No teammates to check in with.', 'info');
401
471
  break;
402
472
  }
403
- const failures: string[] = [];
404
- let queued = 0;
405
- for (const teammate of teammates) {
473
+ // Sequential: each send is a mailbox transaction, and ordering
474
+ // keeps the queued check-ins in teammate order.
475
+ const outcomes = await teammates.reduce(async (previous, teammate) => {
476
+ const done = await previous;
406
477
  try {
407
478
  await send({ to: teammate.alias, kind: 'request', subject: 'Team check-in', body }, true);
408
- queued++;
479
+ return [...done, { alias: teammate.alias, error: undefined as string | undefined }];
409
480
  } catch (error) {
410
- const reason = error instanceof Error ? error.message : String(error);
411
- failures.push(`${teammate.alias}: ${reason}`);
481
+ return [...done, { alias: teammate.alias, error: reason(error) }];
412
482
  }
413
- }
483
+ }, Promise.resolve([] as { alias: string; error?: string }[]));
484
+ const failures = outcomes.filter(item => item.error);
485
+ const queued = outcomes.length - failures.length;
414
486
  const summary = `Queued team check-in for ${queued} teammate${queued === 1 ? '' : 's'}.`;
415
- if (failures.length) ctx!.ui.notify(`${summary}\nNot queued:\n${failures.join('\n')}`, 'warning');
416
- else ctx!.ui.notify(summary, 'info');
487
+ if (failures.length) ui.notify(`${summary}\nNot queued:\n${failures.map(item => `${item.alias}: ${item.error}`).join('\n')}`, 'warning');
488
+ else ui.notify(summary, 'info');
417
489
  break;
418
490
  }
419
491
  case 'send': case 'note': {
@@ -425,17 +497,26 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
425
497
  case 'inbox':
426
498
  for (const message of (await box.history(required())).slice(-20)) pi.appendEntry('team-event', message);
427
499
  break;
428
- case 'pause': required(); paused = true; persist(); ctx!.ui.notify('Team reception paused. Current work is not cancelled.', 'info'); break;
500
+ case 'pause':
501
+ required();
502
+ set(() => ({ paused: true }));
503
+ persist();
504
+ ui.notify('Team reception paused. Current work is not cancelled.', 'info'); break;
429
505
  case 'resume':
430
506
  required();
431
- if (active && ctx!.isIdle()) throw new Error('A result was not persisted. Leave and rejoin to recover; review before retrying work.');
432
- paused = false; budget = 0; lastError = ''; quietReviews = 0; persist(); enqueueTick(); break;
507
+ if (get().active && context.isIdle()) throw new Error('A result was not persisted. Leave and rejoin to recover; review before retrying work.');
508
+ set(() => ({ paused: false, budget: 0, lastError: '', quietReviews: 0 }));
509
+ persist(); enqueueTick(); break;
433
510
  case 'leave':
434
- required(); paused = true;
435
- if (active && !ctx!.isIdle()) { leaving = true; pi.appendEntry('team-membership', null); ctx!.ui.notify('Will leave after reporting current work. No further messages will be processed.', 'info'); }
436
- else { await detach(); }
511
+ required();
512
+ set(() => ({ paused: true }));
513
+ if (get().active && !context.isIdle()) {
514
+ set(() => ({ leaving: true }));
515
+ pi.appendEntry('team-membership', null);
516
+ ui.notify('Will leave after reporting current work. No further messages will be processed.', 'info');
517
+ } else { await detach(); }
437
518
  break;
438
- default: ctx!.ui.notify(HELP, 'info');
519
+ default: ui.notify(HELP, 'info');
439
520
  }
440
521
  } catch (error) { notice(error); }
441
522
  });
@@ -444,8 +525,12 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
444
525
 
445
526
  pi.on('session_start', async (event, context) => {
446
527
  if (context.mode !== 'tui') return;
447
- ctx = context; closed = false; compacting = false; needsCompaction = false; compactionSubject = ''; compactionGeneration++;
448
- teamNames = await box.teams();
528
+ set(session => ({
529
+ ctx: context, closed: false, compacting: false, needsCompaction: false,
530
+ compactionSubject: '', compactionGeneration: session.compactionGeneration + 1,
531
+ }));
532
+ const teamNames = await box.teams();
533
+ set(() => ({ teamNames }));
449
534
  // Only restore this exact session, never a fork's copied membership.
450
535
  const saved = context.sessionManager.getBranch().filter(e => e.type === 'custom' && e.customType === 'team-membership').at(-1);
451
536
  const data = saved?.type === 'custom' ? saved.data as {
@@ -453,39 +538,64 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
453
538
  } | null : null;
454
539
  if (data?.team && data.alias && data.session === context.sessionManager.getSessionId() && event.reason !== 'fork' && event.reason !== 'new') {
455
540
  try {
456
- member = await box.join(data.team, data.alias, data.session, context.cwd);
457
- paused = data.paused ?? false;
458
- needsCompaction = data.needsCompaction ?? false;
459
- compactionSubject = needsCompaction ? data.compactionSubject ?? 'restored team task' : '';
541
+ const member = await box.join(data.team, data.alias, data.session, context.cwd);
542
+ const needsCompaction = data.needsCompaction ?? false;
543
+ set(() => ({
544
+ member,
545
+ paused: data.paused ?? false,
546
+ needsCompaction,
547
+ compactionSubject: needsCompaction ? data.compactionSubject ?? 'restored team task' : '',
548
+ }));
460
549
  persist(); start();
461
550
  } catch (error) { notice(error); }
462
551
  }
463
552
  });
464
- pi.on('before_agent_start', event => member ? { systemPrompt: `${event.systemPrompt}\n\n${PEER_RULES}\nJoined team: ${member.team}; your alias: ${member.alias}.` } : undefined);
465
- pi.on('ui_prompt_start', () => { prompts++; });
466
- pi.on('ui_prompt_end', () => { prompts = Math.max(0, prompts - 1); enqueueTick(); });
553
+ pi.on('before_agent_start', event => {
554
+ const { member } = get();
555
+ return member ? { systemPrompt: `${event.systemPrompt}\n\n${PEER_RULES}\nJoined team: ${member.team}; your alias: ${member.alias}.` } : undefined;
556
+ });
557
+ pi.on('ui_prompt_start', () => { set(session => ({ prompts: session.prompts + 1 })); });
558
+ pi.on('ui_prompt_end', () => {
559
+ set(session => ({ prompts: Math.max(0, session.prompts - 1) }));
560
+ enqueueTick();
561
+ });
467
562
  pi.on('input', event => {
468
563
  if (event.source !== 'interactive') return;
469
- budget = 0;
470
- if (active) { userTakeover = true; paused = true; persist(); }
564
+ set(() => ({ budget: 0 }));
565
+ if (get().active) {
566
+ set(() => ({ userTakeover: true, paused: true }));
567
+ persist();
568
+ }
471
569
  });
472
570
  pi.on('tool_result', (event, context) => {
571
+ const { active, userTakeover } = get();
473
572
  if (active && !userTakeover && !event.isError && ['edit', 'write'].includes(event.toolName) && typeof event.input.path === 'string') {
474
- files.add(resolve(context.cwd, event.input.path.replace(/^@/, '')));
573
+ const path = resolve(context.cwd, event.input.path.replace(/^@/, ''));
574
+ set(session => ({ files: new Set(session.files).add(path) }));
475
575
  }
476
576
  });
477
577
  pi.on('message_end', event => {
478
- if (!active || event.message.role !== 'assistant') return;
479
- finalText = event.message.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
480
- outcome = event.message.stopReason === 'aborted' ? 'interrupted' : event.message.stopReason === 'error' ? 'failed' : 'completed';
578
+ const message = event.message;
579
+ if (!get().active || message.role !== 'assistant') return;
580
+ // Narrowing must happen before the update closure: the callback is not
581
+ // evaluated in this control-flow branch as far as the compiler is concerned.
582
+ const finalText = message.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
583
+ const outcome: Result['outcome'] =
584
+ message.stopReason === 'aborted' ? 'interrupted' : message.stopReason === 'error' ? 'failed' : 'completed';
585
+ set(() => ({ finalText, outcome }));
481
586
  });
482
587
  pi.on('agent_settled', async (_event, context) => {
483
- let shouldCompact = false;
484
- await queue(async () => {
485
- if (!member || !active) return;
588
+ const shouldCompact = await queue(async () => {
589
+ const { member, active } = get();
590
+ if (!member || !active) return false;
486
591
  const finished = active;
487
- const takenOver = userTakeover;
488
- if (takenOver) { outcome = 'interrupted'; finalText = 'User took over the session. Subsequent output was not forwarded. Review before continuing.'; }
592
+ // Read the latest takeover flag: an interactive prompt can land while
593
+ // this handler waits behind the serial queue.
594
+ const takenOver = get().userTakeover;
595
+ if (takenOver) {
596
+ set(() => ({ outcome: 'interrupted', finalText: 'User took over the session. Subsequent output was not forwarded. Review before continuing.' }));
597
+ }
598
+ const { outcome, finalText, files } = get();
489
599
  const report: Result = { outcome, body: finalText.slice(0, 3000) || `Agent turn ${outcome}; no final text. Review the recipient session.`, files: [], tests: [] };
490
600
  for (const file of files) {
491
601
  if (report.files.length >= 50 || file.length > 4096 || Buffer.byteLength(JSON.stringify({ ...report, files: [...report.files, file] })) > 31000) {
@@ -495,32 +605,34 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
495
605
  report.files.push(file);
496
606
  }
497
607
  await box.complete(member, finished.id, report);
498
- active = undefined;
499
- if (outcome !== 'completed') paused = true;
500
- if (leaving) await detach();
501
- else {
502
- persist();
503
- // Result persistence is the task boundary. Compact both executed
504
- // requests and result-review turns before accepting another peer turn.
505
- if (!takenOver) {
506
- needsCompaction = true;
507
- compactionSubject = finished.subject;
508
- persist();
509
- shouldCompact = true;
510
- }
511
- }
512
- }).catch(error => { paused = true; notice(error); });
608
+ set(() => ({ active: undefined, ...(outcome !== 'completed' ? { paused: true } : {}) }));
609
+ if (get().leaving) { await detach(); return false; }
610
+ persist();
611
+ // Result persistence is the task boundary. Compact both executed
612
+ // requests and result-review turns before accepting another peer turn.
613
+ if (takenOver) return false;
614
+ set(() => ({ needsCompaction: true, compactionSubject: finished.subject }));
615
+ persist();
616
+ return true;
617
+ }).catch(error => {
618
+ set(() => ({ paused: true }));
619
+ notice(error);
620
+ return false;
621
+ });
513
622
  if (shouldCompact) compactPendingContext(context);
514
623
  enqueueTick();
515
624
  });
516
625
  pi.on('session_shutdown', async () => {
517
- closed = true; compacting = false; compactionGeneration++; stop();
626
+ set(session => ({ closed: true, compacting: false, compactionGeneration: session.compactionGeneration + 1 }));
627
+ stop();
518
628
  await queue(async () => {
629
+ const { member, active, leaving } = get();
519
630
  if (member) {
520
- if (active && !leaving) { paused = true; persist(); }
631
+ if (active && !leaving) { set(() => ({ paused: true })); persist(); }
521
632
  await box.leave(member).catch(notice);
522
633
  }
523
- member = undefined; active = undefined; ctx?.ui.setWidget('team', undefined);
634
+ set(() => ({ member: undefined, active: undefined }));
635
+ get().ctx?.ui.setWidget('team', undefined);
524
636
  });
525
637
  });
526
638
  }
package/src/mailbox.ts CHANGED
@@ -21,6 +21,13 @@ type Presence = { token: string; status: 'idle' | 'busy' | 'paused'; seen: numbe
21
21
  export const LEASE_MS = 30_000;
22
22
  const MAX_BYTES = 32_000_000;
23
23
  const MAX_ATTEMPTS = 100;
24
+ /** Preallocated attempt sequence: a retry counter without a mutable binding. */
25
+ const ATTEMPTS = Array.from({ length: MAX_ATTEMPTS }, (_, index) => index);
26
+
27
+ function parseMailbox(raw: string): unknown {
28
+ try { return JSON.parse(raw); }
29
+ catch { throw new Error('Invalid mailbox format; preserved for manual recovery'); }
30
+ }
24
31
 
25
32
  export function identifier(value: string): string {
26
33
  if (!/^[a-z][a-z0-9-]{0,47}$/.test(value)) {
@@ -54,22 +61,23 @@ export class Mailbox {
54
61
  return join(this.path(team), 'presence', `${identifier(alias)}.json`);
55
62
  }
56
63
 
64
+ /** Parse either an envelope record or a pre-envelope mailbox. */
65
+ private parse(raw: string): StoreRecord<State> {
66
+ const parsed = parseMailbox(raw);
67
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) &&
68
+ (parsed as { schemaVersion?: unknown }).schemaVersion === 1) {
69
+ return envelope<State>(raw);
70
+ }
71
+ // Legacy mailbox without an envelope: accepted once as revision 0 and
72
+ // rewritten as an envelope record on the next publication.
73
+ if (!Value.Check(StateSchema, parsed)) throw new Error('Invalid mailbox format; preserved for manual recovery');
74
+ return { revision: 0, payload: parsed as State };
75
+ }
76
+
57
77
  /** Envelope records plus transparent migration of pre-envelope mailboxes. */
58
78
  private normalize(team: string) {
59
79
  return (raw: string): StoreRecord<State> => {
60
- let parsed: unknown;
61
- try { parsed = JSON.parse(raw); }
62
- catch { throw new Error('Invalid mailbox format; preserved for manual recovery'); }
63
- let record: StoreRecord<State>;
64
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) &&
65
- (parsed as { schemaVersion?: unknown }).schemaVersion === 1) {
66
- record = envelope<State>(raw);
67
- } else {
68
- // Legacy mailbox without an envelope: accepted once as revision 0 and
69
- // rewritten as an envelope record on the next publication.
70
- if (!Value.Check(StateSchema, parsed)) throw new Error('Invalid mailbox format; preserved for manual recovery');
71
- record = { revision: 0, payload: parsed as State };
72
- }
80
+ const record = this.parse(raw);
73
81
  const state = record.payload;
74
82
  if (!Value.Check(StateSchema, state)) throw new Error('Invalid mailbox format; preserved for manual recovery');
75
83
  if (state.members.some(m => m.team !== team) || state.messages.some(m => m.team !== team)) {
@@ -89,23 +97,21 @@ export class Mailbox {
89
97
  }
90
98
 
91
99
  private async readPresence(team: string): Promise<Map<string, Presence>> {
92
- const map = new Map<string, Presence>();
93
- let names: string[];
94
- try { names = await readdir(join(this.path(team), 'presence')); }
95
- catch { return map; }
96
- await Promise.all(names.map(async name => {
97
- if (!/^[a-z][a-z0-9-]{0,47}\.json$/.test(name)) return;
100
+ const names = await readdir(join(this.path(team), 'presence')).catch(() => [] as string[]);
101
+ const entries = await Promise.all(names.map(async (name): Promise<[string, Presence] | undefined> => {
102
+ if (!/^[a-z][a-z0-9-]{0,47}\.json$/.test(name)) return undefined;
98
103
  try {
99
104
  const raw = await readFile(join(this.path(team), 'presence', name), 'utf8');
100
- if (raw.length > 4096) return;
105
+ if (raw.length > 4096) return undefined;
101
106
  const presence = JSON.parse(raw) as Presence;
102
107
  if (typeof presence?.seen === 'number' && typeof presence?.token === 'string' &&
103
108
  ['idle', 'busy', 'paused'].includes(presence?.status)) {
104
- map.set(name.slice(0, -'.json'.length), presence);
109
+ return [name.slice(0, -'.json'.length), presence];
105
110
  }
106
111
  } catch { /* A presence file may be replaced or removed mid-read. */ }
112
+ return undefined;
107
113
  }));
108
- return map;
114
+ return new Map(entries.filter(entry => !!entry));
109
115
  }
110
116
 
111
117
  private alive(member: Member, presence: Map<string, Presence>): boolean {
@@ -132,24 +138,19 @@ export class Mailbox {
132
138
  try { await lstat(this.path(team)); }
133
139
  catch { throw new Error(`Unknown team "${team}". Use /team list or /team create.`); }
134
140
  await privateDirectory(this.path(team));
135
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
141
+ for (const attempt of ATTEMPTS) {
136
142
  const record = await this.readState(team);
137
143
  const state = record.payload;
138
144
  const before = JSON.stringify(state);
139
145
  const presence = await this.readPresence(team);
140
- const swept: string[] = [];
141
- for (const member of state.members) {
142
- if (member.status !== 'offline' && !this.alive(member, presence)) {
143
- this.disconnect(state, member);
144
- swept.push(member.alias);
145
- }
146
- }
146
+ const swept = state.members.filter(member => member.status !== 'offline' && !this.alive(member, presence));
147
+ for (const member of swept) this.disconnect(state, member);
147
148
  const result = action(state);
148
149
  if (before === JSON.stringify(state)) return result;
149
150
  if (!Value.Check(StateSchema, state)) throw new Error('Invalid mailbox format; refusing to write');
150
151
  try {
151
152
  await publish(this.recordPath(team), record.revision, state, this.normalize(team), { maxBytes: MAX_BYTES });
152
- await Promise.all(swept.map(alias => unlink(this.presencePath(team, alias)).catch(() => {})));
153
+ await Promise.all(swept.map(member => unlink(this.presencePath(team, member.alias)).catch(() => {})));
153
154
  return result;
154
155
  } catch (error) {
155
156
  const code = (error as { code?: string }).code;
package/src/store.ts CHANGED
@@ -47,14 +47,19 @@ function assertSafeFile(path: string, info: { isFile(): boolean; size: number; m
47
47
  }
48
48
  }
49
49
 
50
- /** Lock-free read. Missing records stay missing; corrupt records throw. */
51
- export async function readRecord<T>(path: string, normalize: Normalize<T>, maxBytes: number): Promise<Record<T> | undefined> {
52
- let handle;
53
- try { handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); }
50
+ /** Resolve to `undefined` when the target is absent; other errors propagate. */
51
+ async function absentAsUndefined<T>(work: Promise<T>): Promise<T | undefined> {
52
+ try { return await work; }
54
53
  catch (error) {
55
54
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
56
55
  throw error;
57
56
  }
57
+ }
58
+
59
+ /** Lock-free read. Missing records stay missing; corrupt records throw. */
60
+ export async function readRecord<T>(path: string, normalize: Normalize<T>, maxBytes: number): Promise<Record<T> | undefined> {
61
+ const handle = await absentAsUndefined(open(path, constants.O_RDONLY | constants.O_NOFOLLOW));
62
+ if (!handle) return undefined;
58
63
  try {
59
64
  assertSafeFile(path, await handle.stat(), maxBytes);
60
65
  return normalize(await handle.readFile('utf8'));
@@ -67,12 +72,8 @@ export async function readRecord<T>(path: string, normalize: Normalize<T>, maxBy
67
72
  const cache = new Map<string, { ino: number; size: number; mtimeMs: number; record: Record<unknown> | undefined }>();
68
73
 
69
74
  export async function readRecordCached<T>(path: string, normalize: Normalize<T>, maxBytes: number): Promise<Record<T> | undefined> {
70
- let info;
71
- try { info = await stat(path); }
72
- catch (error) {
73
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') { cache.delete(path); return undefined; }
74
- throw error;
75
- }
75
+ const info = await absentAsUndefined(stat(path));
76
+ if (!info) { cache.delete(path); return undefined; }
76
77
  const hit = cache.get(path);
77
78
  if (hit && hit.ino === info.ino && hit.size === info.size && hit.mtimeMs === info.mtimeMs) {
78
79
  return hit.record as Record<T> | undefined;
@@ -103,25 +104,28 @@ export async function writeAtomic(path: string, text: string, durability: Durabi
103
104
  } finally { await unlink(tmp).catch(() => {}); }
104
105
  }
105
106
 
106
- async function acquireLock(lockPath: string) {
107
- for (let attempt = 0; ; attempt++) {
108
- try { return await open(lockPath, 'wx', 0o600); }
109
- catch (error) {
110
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
111
- // A crashed writer can leave its lock behind; publication takes
112
- // microseconds, so a lock older than STALE_LOCK_MS is safe to break.
113
- if (attempt === 0) {
114
- const info = await stat(lockPath).catch(() => undefined);
115
- if (info && Date.now() - info.mtimeMs > STALE_LOCK_MS) {
116
- await unlink(lockPath).catch(() => {});
117
- continue;
118
- }
119
- }
120
- throw Object.assign(new Error('Another writer holds this record.'), { code: 'RECORD_LOCKED' });
121
- }
107
+ const locked = () => Object.assign(new Error('Another writer holds this record.'), { code: 'RECORD_LOCKED' });
108
+
109
+ /** Resolve to `undefined` when the lock is already held; other errors propagate. */
110
+ async function tryLock(lockPath: string) {
111
+ try { return await open(lockPath, 'wx', 0o600); }
112
+ catch (error) {
113
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') return undefined;
114
+ throw error;
122
115
  }
123
116
  }
124
117
 
118
+ async function acquireLock(lockPath: string) {
119
+ const held = await tryLock(lockPath);
120
+ if (held) return held;
121
+ // A crashed writer can leave its lock behind; publication takes
122
+ // microseconds, so a lock older than STALE_LOCK_MS is safe to break.
123
+ const info = await stat(lockPath).catch(() => undefined);
124
+ if (!info || Date.now() - info.mtimeMs <= STALE_LOCK_MS) throw locked();
125
+ await unlink(lockPath).catch(() => {});
126
+ return await tryLock(lockPath) ?? (() => { throw locked(); })();
127
+ }
128
+
125
129
  async function pruneRevisions(dir: string, latest: number): Promise<void> {
126
130
  const revisionsDir = join(dir, 'revisions');
127
131
  const names = await readdir(revisionsDir).catch(() => [] as string[]);