@prjct.app/pi-team 0.5.7 → 0.6.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,19 @@
1
+ ## [0.6.1](https://github.com/prjct-app/pi-team/compare/v0.6.0...v0.6.1) (2026-09-11)
2
+
3
+ ### Bug Fixes
4
+
5
+ * preserve mailbox locks across rolling upgrades ([3509903](https://github.com/prjct-app/pi-team/commit/3509903026205a0a3d00fcd52eecb3b970ba4d7b))
6
+
7
+ ## [0.6.0](https://github.com/prjct-app/pi-team/compare/v0.5.7...v0.6.0) (2026-09-10)
8
+
9
+ ### Features
10
+
11
+ * manage team and member lifecycle ([0a60766](https://github.com/prjct-app/pi-team/commit/0a607661f447fa0d78bb0c100da65f68e1cf947b))
12
+
13
+ ### Bug Fixes
14
+
15
+ * exclude current session from teammate discovery ([a4f340b](https://github.com/prjct-app/pi-team/commit/a4f340bad2a979216f2e8ef579b456355ace7003))
16
+
1
17
  ## [0.5.7](https://github.com/prjct-app/pi-team/compare/v0.5.6...v0.5.7) (2026-09-10)
2
18
 
3
19
  ### Bug Fixes
package/README.md CHANGED
@@ -80,9 +80,13 @@ reloaded. `/new` and `/fork` start unaffiliated sessions on purpose.
80
80
  | Command | Meaning |
81
81
  | --- | --- |
82
82
  | `/team create shop` | Create explicitly; does not join automatically |
83
+ | `/team delete shop` | Permanently delete an inactive team after confirmation |
84
+ | `/team rename-team shop store` | Rename a team after every member is offline |
83
85
  | `/team join shop backend` | Register this session and enable automatic reception |
84
86
  | `/team list` | List teams; refresh team-name completion |
85
- | `/team members` | Show aliases, cwd, and idle/busy/paused/offline status |
87
+ | `/team members` | Show other aliases, cwd, and idle/busy/paused/offline status; excludes this session |
88
+ | `/team remove backend` | Remove an offline alias and interrupt its unresolved work after confirmation |
89
+ | `/team rename-member backend api` | Rename your own alias, or an offline teammate, while preserving its history and queued work |
86
90
  | `/team status` | Show every unresolved requester → assignee relationship |
87
91
  | `/team wake [message]` | Queue an actionable check-in for every other teammate |
88
92
  | `/team send backend Implement login` | Queue a request that can start work |
@@ -95,18 +99,24 @@ reloaded. `/new` and `/fork` start unaffiliated sessions on purpose.
95
99
  Names and aliases are 1–48 lowercase letters, digits, or hyphens, starting with a
96
100
  letter. Unknown teams are rejected, never implicitly created; duplicate live
97
101
  aliases are rejected. Sending to an offline **known** alias queues until someone
98
- rejoins it. Tab completion covers subcommands, discovered teams, and teammates.
102
+ rejoins it. Removing that alias instead settles every unresolved request involving
103
+ it; incoming requests produce an interrupted result so their requesters stop waiting.
104
+ Renaming an alias rewrites its message addresses so queued work follows the new
105
+ name. Team deletion and rename require every member to be offline, and destructive
106
+ operations require confirmation. Tab completion covers subcommands, discovered
107
+ teams, and teammates.
99
108
 
100
109
  ## Agent tools
101
110
 
102
- - `team_members` — discover teammates and their status.
111
+ - `team_members` — discover other teammates and their status; excludes this session.
103
112
  - `team_send` — send `{ to, kind: "request" | "note", subject, body }`.
104
113
  - `team_status` — outstanding work: what you emitted and is unresolved, what is
105
114
  queued for you, results awaiting your review, and third-party team activity.
106
115
 
107
116
  Tools cannot create teams, join, resume reception, change permissions, or launch
108
- terminals; they require membership you established. A request returns **queued**,
109
- never "task completed".
117
+ terminals; they require membership you established. Discovery results and recipient
118
+ autocomplete exclude the current session, and sending to yourself is rejected at
119
+ the mailbox boundary. A request returns **queued**, never "task completed".
110
120
 
111
121
  ## How delivery works
112
122
 
@@ -170,11 +180,13 @@ defaults, not user-configurable yet.
170
180
  | --- | --- |
171
181
  | A request stays queued | Run `/team status`. The recipient may be busy, paused, offline, missing a model, or typing. After five minutes, review turns chase it or surface the blockage. |
172
182
  | `Team auto-turn limit reached` | Five automatic turns ran without user input. Review the transcript, then `/team resume`. |
183
+ | `Message not claimed by this session` | The durable claim changed before settlement. Review for partial effects, then `/reload` or leave and rejoin before `/team resume`. After updating pi-team, reload every live teammate so all sessions use the same runtime. |
173
184
  | `Membership expired or replaced` | Another live session took your alias. Rejoin, choosing a new alias if the old one is in use. |
174
185
  | `Recipient inbox full` / `Sender inbox full` | Fifty unsettled deliveries per member, one slot reserved per outstanding request. Let the teammate drain; notes need no reservation. |
175
186
  | `Team history full (500 records)` | At capacity; history is never deleted. Create a fresh team and rejoin. |
176
187
  | Repeated storage warnings | Conflicts retry automatically and never pause reception. If one persists, check that the teams directory is on a local disk and report it. |
177
188
  | A teammate went offline mid-task | Its claimed work is interrupted and the emitter receives that result; it is not replayed. Review the worktree, then resend explicitly. |
189
+ | Work remains queued for an alias that will not return | Use `/team remove <alias>` to interrupt and settle its unresolved work, or `/team rename-member <alias> <new-alias>` to preserve the queue under a replacement alias. |
178
190
 
179
191
  ## Development
180
192
 
@@ -22,10 +22,17 @@ hard-links it into a bounded `revisions/` history, and atomically renames it int
22
22
  place, so a concurrent read sees either the whole previous record or the whole
23
23
  next one. The history doubles as recovery evidence for an interrupted write.
24
24
 
25
- Writers compare-and-swap on the revision under a short-lived sibling lock.
26
- A conflict fails fast and the caller retries against a fresh read, so many agents
27
- write concurrently instead of queueing behind a team-wide lock. A lock abandoned
28
- by a crashed writer is reclaimed after ten seconds.
25
+ Writers compare-and-swap on the revision under a short-lived per-team lock in
26
+ `~/.pi/agent/teams/.locks/`. Keeping the lock outside the team directory lets
27
+ rename and deletion fence stale publishers without allowing them to recreate a
28
+ moved directory. Normal publications also acquire the pre-0.6 compatibility
29
+ lock beside `state.json`, after the stable lock. This overlap is required while
30
+ sessions from both sides of the lock migration remain alive during a rolling
31
+ reload; without it, two versions could publish the same next revision and lose
32
+ a claim. The compatibility lock is opened without creating its parent, so a
33
+ stale writer still cannot resurrect a deleted team. A conflict fails fast and
34
+ the caller retries against a fresh read. A lock abandoned by a crashed writer
35
+ is reclaimed after ten seconds.
29
36
 
30
37
  Because every publication renames a **new inode** into place, readers can safely
31
38
  cache a parsed record keyed on `(inode, size, mtime)`: a write by any process
@@ -41,14 +48,16 @@ and never touch `state.json`.
41
48
  These files are deliberately non-durable: the atomic rename is kept, both fsyncs
42
49
  are not. Presence expires after 30 seconds and is rewritten every 2, so a write
43
50
  lost to a crash only makes a member look offline sooner — never alive longer.
44
- A member is also considered gone as soon as its recorded process has exited.
51
+ A member is also considered gone as soon as its recorded process has exited. A
52
+ heartbeat never creates a missing parent directory, so one already in flight
53
+ cannot resurrect a team after rename or deletion.
45
54
 
46
55
  ## Request lifecycle
47
56
 
48
57
  A request is queued, claimed when the recipient is idle, worked on, and settled
49
- with a result delivered back to the emitter. The recipient compacts before
50
- accepting another team turn; the emitter verifies the result against its original
51
- request and replies in-thread only if something is missing.
58
+ with a result delivered back to the emitter. Once settlement is durable, the
59
+ recipient can accept another team turn; the emitter verifies the result against
60
+ its original request and replies in-thread only if something is missing.
52
61
 
53
62
  States are `pending`, `processing`, `completed`, `interrupted`, and `seen`
54
63
  (notes already displayed).
@@ -59,13 +68,31 @@ A session that dies holding a claim would otherwise leave its requester waiting
59
68
  forever, so peers interrupt the claim on its behalf and the requester receives an
60
69
  `interrupted` result.
61
70
 
62
- Members are never removed from the record leaving only marks them offline — so
63
- "someone is offline" is permanently true once anyone has ever left and cannot be
64
- used to decide when to sweep. A snapshot instead reports `sweepable`, true only
65
- when a member the record still counts as connected is actually dead **and** still
66
- holds a claim, which is the only case where sweeping changes anything. Everything
67
- else degrades correctly without it: rejoining re-admits a stale alias on its own,
68
- and displayed status comes from presence rather than the record.
71
+ Leaving marks a member offline but retains its address and history so the same
72
+ alias can rejoin. Therefore "someone is offline" is not enough to decide when to
73
+ sweep. A snapshot instead reports `sweepable`, true only when a member the record
74
+ still counts as connected is actually dead **and** still holds a claim, which is
75
+ the only case where sweeping changes anything. Everything else degrades correctly
76
+ without it: rejoining re-admits a stale alias on its own, and displayed status
77
+ comes from presence rather than the record.
78
+
79
+ ## Team and member lifecycle
80
+
81
+ Lifecycle changes are user-only commands; agents receive no tool that can delete
82
+ or rename identities. Removing a member is allowed only while it is offline. The
83
+ operation removes the roster entry, cancels unresolved work emitted by that alias,
84
+ and turns requests addressed to it into interrupted results for their requesters.
85
+ Renaming rewrites every message endpoint so pending work and history follow the new
86
+ alias. A live session may rename itself; only offline peers can be renamed by
87
+ another member. Ownership tokens fence the old alias after either operation.
88
+
89
+ Team rename and deletion require every recorded member to be offline. They acquire
90
+ stable locks for both names in lexical order, preventing deadlock and fencing
91
+ concurrent joins or publishers. Rename moves the directory first and then writes a
92
+ new revision with the new team name. If the process stops between those steps,
93
+ repeating the same rename recognizes and completes that partial state. Deletion
94
+ first moves the directory to a hidden tombstone and then recursively removes it,
95
+ so readers never observe a partially deleted public team directory.
69
96
 
70
97
  ## Context budget
71
98
 
@@ -92,12 +119,16 @@ Consequences worth knowing:
92
119
  - Each `team_status` call is a point-in-time snapshot. Earlier results in the
93
120
  same conversation are stale but cannot be retracted, which is why each one is
94
121
  kept small.
122
+ - Teammate discovery, status rosters, `/team members`, and recipient completion
123
+ omit the current alias. The mailbox independently rejects self-addressed
124
+ messages, so a stale UI or direct tool call cannot create a self-reply loop.
95
125
 
96
126
  ## Recovery and guarantees
97
127
 
98
- Ownership tokens fence out replaced sessions. Pending messages survive
99
- disconnection. Claimed work is marked interrupted on disconnect or rejoin and is
100
- **not automatically replayed**, since edits may already have happened.
128
+ Ownership tokens fence out replaced, renamed, or removed sessions. Pending
129
+ messages survive disconnection and alias/team rename. Claimed work is marked
130
+ interrupted on disconnect or rejoin and is **not automatically replayed**, since
131
+ edits may already have happened.
101
132
 
102
133
  This favours avoiding duplicate side effects over guaranteed execution. **There is
103
134
  no exactly-once guarantee** for filesystem changes or model actions: a crash after
@@ -137,8 +168,8 @@ or protection of secrets from other processes under the same OS user.
137
168
  ## Pi interfaces used
138
169
 
139
170
  `registerCommand`, `registerTool`, `sendMessage`, `appendEntry`, custom entry and
140
- message renderers, `setWidget`, `getEditorText`, `isIdle`, `hasPendingMessages`,
141
- `compact`, session lifecycle events, UI prompt events, `tool_result`,
171
+ message renderers, `setWidget`, `getEditorText`, `confirm`, `isIdle`,
172
+ `hasPendingMessages`, session lifecycle events, UI prompt events, `tool_result`,
142
173
  `message_end`, and `agent_settled`. All public and documented; no host internals
143
174
  are imported, no prototypes patched, and peer text is never shell-evaluated or
144
175
  expanded as file mentions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-team",
3
- "version": "0.5.7",
3
+ "version": "0.6.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": [
package/src/index.ts CHANGED
@@ -7,8 +7,8 @@ import { Type } from 'typebox';
7
7
  import { StringEnum } from '@earendil-works/pi-ai';
8
8
  import { Mailbox, type Membership, type Message, type Outgoing, type Result, type Snapshot } from './mailbox.ts';
9
9
 
10
- const COMMANDS = ['create', 'join', 'list', 'members', 'status', 'wake', 'send', 'note', 'inbox', 'pause', 'resume', 'leave'];
11
- const HELP = '/team create <team> | join <team> <alias> | list | members | status | wake [message] | send <alias> <text> | note <alias> <text> | inbox | pause | resume | leave';
10
+ const COMMANDS = ['create', 'delete', 'rename-team', 'join', 'list', 'members', 'remove', 'rename-member', 'status', 'wake', 'send', 'note', 'inbox', 'pause', 'resume', 'leave'];
11
+ const HELP = '/team create <team> | delete <team> | rename-team <team> <new-team> | join <team> <alias> | list | members | remove <alias> | rename-member <alias> <new-alias> | status | wake [message] | send <alias> <text> | note <alias> <text> | inbox | pause | resume | leave';
12
12
  const TEAM_CHECK_IN = `Team check-in: report what you are working on, what remains, blockers, and your next concrete step.
13
13
  If you are waiting on another teammate, use team_send to ask them directly for the missing input.
14
14
  Do not stay idle: complete any pending work you can finish within the current user's authorization and project rules.
@@ -162,7 +162,7 @@ const INITIAL: Session = {
162
162
 
163
163
  /** Cleared on join, restore, and leave so a new membership starts unbiased. */
164
164
  const MEMBERSHIP_RESET = {
165
- paused: false, leaving: false, closed: false,
165
+ paused: false, leaving: false, closed: false, aliases: [],
166
166
  budget: 0, lastReview: 0, quietReviews: 0, lastRevision: -1,
167
167
  } as const;
168
168
 
@@ -213,7 +213,7 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
213
213
  }
214
214
  /** Forget the current membership without leaving the mailbox. */
215
215
  function forget() {
216
- set(() => ({ member: undefined, active: undefined, leaving: false }));
216
+ set(() => ({ member: undefined, active: undefined, leaving: false, aliases: [] }));
217
217
  persist();
218
218
  showWidget(undefined);
219
219
  }
@@ -232,7 +232,7 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
232
232
  const text = reason(error);
233
233
  if (text !== get().lastError) get().ctx?.ui.notify(`Team: ${text}`, 'warning');
234
234
  set(() => ({ lastError: text }));
235
- if (text.includes('Membership expired or replaced')) {
235
+ if (text.includes('Membership expired or replaced') || text.startsWith('Unknown team "')) {
236
236
  stop();
237
237
  forget();
238
238
  }
@@ -273,7 +273,7 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
273
273
  set(() => ({ lastHeartbeat: Date.now() }));
274
274
  }
275
275
  const snap = await box.snapshot(member);
276
- set(() => ({ aliases: snap.members.map(m => m.alias) }));
276
+ set(() => ({ aliases: snap.members.filter(peer => peer.alias !== member.alias).map(peer => peer.alias) }));
277
277
  const inbox = snap.messages.filter(m => m.to === member.alias && m.state === 'pending');
278
278
  const pending = inbox.length;
279
279
  const { paused, active } = get();
@@ -375,11 +375,13 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
375
375
  pi.registerMessageRenderer<{ outstanding?: { to: string; subject: string }[] }>('team-review', (message, { expanded }) => reviewView(message.details, expanded));
376
376
 
377
377
  pi.registerTool({
378
- name: 'team_members', label: 'Team members', description: 'List teammates and their status in the joined local team. Does not create agents.',
378
+ name: 'team_members', label: 'Team members', description: 'List other teammates and their status in the joined local team, excluding this session. Does not create agents.',
379
379
  parameters: Type.Object({}),
380
380
  async execute() {
381
- const members = await queue(() => box.members(required()));
382
- const safe = members.map(({ alias, cwd, status }) => ({ alias, cwd: excerptPath(cwd, MEMBER_CWD_EXCERPT), status }));
381
+ const current = required();
382
+ const members = await queue(() => box.members(current));
383
+ const safe = members.filter(member => member.alias !== current.alias)
384
+ .map(({ alias, cwd, status }) => ({ alias, cwd: excerptPath(cwd, MEMBER_CWD_EXCERPT), status }));
383
385
  return { content: [{ type: 'text', text: JSON.stringify(safe) }], details: {} };
384
386
  },
385
387
  });
@@ -425,18 +427,19 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
425
427
  otherTeamWork: bounded(byAge(snap.flow.filter(item => item.from !== current.alias && item.to !== current.alias))
426
428
  .map(item => ({ from: item.from, to: item.to, subject: subject(item.subject), state: item.state,
427
429
  ageMinutes: age(item.created), assigneeStatus: status(item.to) }))),
428
- teammates: snap.members.map(m => ({ alias: m.alias, status: m.status })),
430
+ teammates: snap.members.filter(member => member.alias !== current.alias)
431
+ .map(member => ({ alias: member.alias, status: member.status })),
429
432
  }) }], details: {} };
430
433
  },
431
434
  });
432
435
 
433
436
  pi.registerCommand('team', {
434
- description: 'Local team messaging: create, join, list, members, status, wake, send, note, inbox, pause, resume, leave',
437
+ description: 'Local team messaging and lifecycle management',
435
438
  getArgumentCompletions(prefix) {
436
439
  const parts = prefix.split(/\s+/);
437
440
  const values = parts.length === 1 ? COMMANDS
438
- : parts.length === 2 && parts[0] === 'join' ? get().teamNames
439
- : parts.length === 2 && ['send', 'note'].includes(parts[0]) ? get().aliases
441
+ : parts.length === 2 && ['join', 'delete', 'rename-team'].includes(parts[0]) ? get().teamNames
442
+ : parts.length === 2 && ['send', 'note', 'remove', 'rename-member'].includes(parts[0]) ? get().aliases
440
443
  : [];
441
444
  const stem = parts.slice(0, -1).join(' ');
442
445
  return values.filter(v => v.startsWith(parts.at(-1) ?? '')).map(v => ({ value: `${stem ? stem + ' ' : ''}${v}`, label: v }));
@@ -456,6 +459,25 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
456
459
  set(() => ({ teamNames }));
457
460
  ui.notify(`Created ${a}. Join with /team join ${a} <alias>.`, 'info'); break;
458
461
  }
462
+ case 'delete': {
463
+ if (!a || b) throw new Error('Usage: /team delete <team>');
464
+ if (get().member?.team === a) throw new Error('Leave this team before deleting it.');
465
+ if (!await ui.confirm('Delete team?', `Delete "${a}" and all of its members, messages, and history? This cannot be undone.`)) {
466
+ ui.notify('Team deletion cancelled.', 'info'); break;
467
+ }
468
+ await box.deleteTeam(a);
469
+ const teamNames = await box.teams();
470
+ set(() => ({ teamNames }));
471
+ ui.notify(`Deleted ${a}.`, 'info'); break;
472
+ }
473
+ case 'rename-team': {
474
+ if (!a || !b || rest.length) throw new Error('Usage: /team rename-team <team> <new-team>');
475
+ if (get().member?.team === a) throw new Error('Leave this team before renaming it.');
476
+ await box.renameTeam(a, b);
477
+ const teamNames = await box.teams();
478
+ set(() => ({ teamNames }));
479
+ ui.notify(`Renamed ${a} to ${b}.`, 'info'); break;
480
+ }
459
481
  case 'join': {
460
482
  if (get().member) throw new Error('Leave the current team before joining another.');
461
483
  if (!a || !b || rest.length) throw new Error('Usage: /team join <team> <alias>');
@@ -469,8 +491,42 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
469
491
  set(() => ({ teamNames }));
470
492
  ui.notify(teamNames.join('\n') || 'No teams. Use /team create <team>.', 'info'); break;
471
493
  }
472
- case 'members':
473
- ui.notify((await box.members(required())).map(m => `${m.alias} · ${m.status} · ${m.cwd}`).join('\n'), 'info'); break;
494
+ case 'members': {
495
+ const current = required();
496
+ const teammates = (await box.members(current)).filter(member => member.alias !== current.alias);
497
+ ui.notify(teammates.map(member => `${member.alias} · ${member.status} · ${member.cwd}`).join('\n') || 'No teammates.', 'info'); break;
498
+ }
499
+ case 'remove': {
500
+ if (!a || b) throw new Error('Usage: /team remove <alias>');
501
+ const current = required();
502
+ const target = (await box.members(current)).find(candidate => candidate.alias === a);
503
+ if (!target) throw new Error(`Unknown teammate "${a}"`);
504
+ if (target.status !== 'offline') throw new Error(`Teammate "${a}" is active; ask them to leave first.`);
505
+ if (!await ui.confirm('Remove teammate?', `Remove "${a}" and interrupt every queued or active request involving that alias?`)) {
506
+ ui.notify('Teammate removal cancelled.', 'info'); break;
507
+ }
508
+ const result = await box.removeMember(current, a);
509
+ const aliases = (await box.members(current)).filter(member => member.alias !== current.alias)
510
+ .map(member => member.alias);
511
+ set(() => ({ aliases }));
512
+ ui.notify(`Removed ${a}; settled ${result.settled} unresolved item${result.settled === 1 ? '' : 's'}.`, 'info'); break;
513
+ }
514
+ case 'rename-member': {
515
+ if (!a || !b || rest.length) throw new Error('Usage: /team rename-member <alias> <new-alias>');
516
+ const current = required();
517
+ if (get().active && current.alias === a) throw new Error('Finish the current team task before renaming this session.');
518
+ const renamed = await box.renameMember(current, a, b);
519
+ const owner = current.alias === a ? renamed : current;
520
+ if (current.alias === a) {
521
+ set(() => ({ member: renamed }));
522
+ persist();
523
+ }
524
+ const aliases = (await box.members(owner)).filter(member => member.alias !== owner.alias)
525
+ .map(member => member.alias);
526
+ set(() => ({ aliases }));
527
+ enqueueTick();
528
+ ui.notify(`Renamed ${a} to ${b}.`, 'info'); break;
529
+ }
474
530
  case 'status': {
475
531
  const snap = await box.snapshot(required());
476
532
  const lines = flowLines(snap);
package/src/mailbox.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { lstat, mkdir, readdir, readFile, unlink } from 'node:fs/promises';
2
+ import { lstat, mkdir, readdir, readFile, rename, rm, unlink } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { Value } from 'typebox/value';
5
5
  import { ResultSchema, StateSchema } from './schema.ts';
6
- import { envelope, publish, readRecord, readRecordCached, writeAtomic, type Record as StoreRecord } from './store.ts';
6
+ import { envelope, publish, publishLocked, readRecord, readRecordCached, withFileLock, writeAtomic, type Record as StoreRecord } from './store.ts';
7
7
 
8
8
  export type Membership = { team: string; alias: string; session: string; token: string };
9
9
  export type Member = Membership & { cwd: string; pid: number; seen: number; status: 'idle' | 'busy' | 'paused' | 'offline' };
@@ -66,6 +66,23 @@ export class Mailbox {
66
66
 
67
67
  private path(team: string): string { return join(this.root, identifier(team)); }
68
68
  private recordPath(team: string): string { return join(this.path(team), 'state.json'); }
69
+ private lockPath(team: string): string { return join(this.root, '.locks', `${identifier(team)}.lock`); }
70
+ private async prepareRoot(): Promise<void> {
71
+ await privateDirectory(this.root);
72
+ await privateDirectory(join(this.root, '.locks'));
73
+ }
74
+ private async withTeamLocks<T>(teams: string[], action: () => Promise<T>): Promise<T> {
75
+ await this.prepareRoot();
76
+ const locks = [...new Set(teams.map(team => this.lockPath(team)))].sort();
77
+ const acquire = (index: number): Promise<T> => index === locks.length
78
+ ? action()
79
+ : withFileLock(locks[index], () => acquire(index + 1));
80
+ return acquire(0);
81
+ }
82
+ private recordOptions(team: string, payloadJson?: string) {
83
+ return { maxBytes: MAX_BYTES, lockPath: this.lockPath(team), ...(payloadJson ? { payloadJson } : {}) };
84
+ }
85
+
69
86
  private presencePath(team: string, alias: string): string {
70
87
  return join(this.path(team), 'presence', `${identifier(alias)}.json`);
71
88
  }
@@ -143,7 +160,7 @@ export class Mailbox {
143
160
  * read; business errors thrown by the action abort immediately.
144
161
  */
145
162
  private async mutate<T>(team: string, action: (state: State) => T): Promise<T> {
146
- await privateDirectory(this.root);
163
+ await this.prepareRoot();
147
164
  try { await lstat(this.path(team)); }
148
165
  catch { throw new Error(`Unknown team "${team}". Use /team list or /team create.`); }
149
166
  await privateDirectory(this.path(team));
@@ -164,8 +181,7 @@ export class Mailbox {
164
181
  if (before === after) return result;
165
182
  if (!Value.Check(StateSchema, state)) throw new Error('Invalid mailbox format; refusing to write');
166
183
  try {
167
- await publish(this.recordPath(team), record.revision, state, this.normalize(team),
168
- { maxBytes: MAX_BYTES, payloadJson: after });
184
+ await publish(this.recordPath(team), record.revision, state, this.normalize(team), this.recordOptions(team, after));
169
185
  await Promise.all(swept.map(member => unlink(this.presencePath(team, member.alias)).catch(() => {})));
170
186
  return result;
171
187
  } catch (error) {
@@ -178,22 +194,106 @@ export class Mailbox {
178
194
  }
179
195
 
180
196
  async create(team: string): Promise<void> {
181
- await privateDirectory(this.root);
182
- try { await mkdir(this.path(team), { mode: 0o700 }); }
183
- catch (error) {
184
- if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error(`Team "${team}" already exists`);
185
- throw error;
186
- }
187
- await publish(this.recordPath(team), 0, { version: 1, members: [], messages: [] } satisfies State,
188
- this.normalize(team), { maxBytes: MAX_BYTES });
197
+ identifier(team);
198
+ await this.withTeamLocks([team], async () => {
199
+ const exists = await lstat(this.path(team)).then(() => true, error => {
200
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
201
+ throw error;
202
+ });
203
+ if (exists) throw new Error(`Team "${team}" already exists`);
204
+ await mkdir(this.path(team), { mode: 0o700 });
205
+ try {
206
+ await publishLocked(this.recordPath(team), 0, { version: 1, members: [], messages: [] } satisfies State,
207
+ this.normalize(team), { maxBytes: MAX_BYTES });
208
+ } catch (error) {
209
+ await rm(this.path(team), { recursive: true, force: true });
210
+ throw error;
211
+ }
212
+ });
189
213
  }
190
214
 
191
215
  async teams(): Promise<string[]> {
192
- await privateDirectory(this.root);
216
+ await this.prepareRoot();
193
217
  const entries = await readdir(this.root, { withFileTypes: true });
194
218
  return entries.filter(e => e.isDirectory() && /^[a-z][a-z0-9-]{0,47}$/.test(e.name)).map(e => e.name).sort();
195
219
  }
196
220
 
221
+ private async disconnectInactiveMembers(team: string, state: State): Promise<void> {
222
+ const presence = await this.readPresence(team);
223
+ const active = state.members.filter(member => member.status !== 'offline' && this.alive(member, presence));
224
+ if (active.length) throw new Error(`Team "${team}" has active member${active.length === 1 ? '' : 's'}: ${active.map(member => member.alias).join(', ')}`);
225
+ for (const member of state.members.filter(candidate => candidate.status !== 'offline')) this.disconnect(state, member);
226
+ }
227
+
228
+ private renameState(state: State, from: string, to: string): State {
229
+ return {
230
+ version: 1,
231
+ members: state.members.map(member => ({ ...member, team: member.team === from ? to : member.team })),
232
+ messages: state.messages.map(message => ({ ...message, team: message.team === from ? to : message.team })),
233
+ };
234
+ }
235
+
236
+ async renameTeam(from: string, to: string): Promise<void> {
237
+ identifier(from); identifier(to);
238
+ if (from === to) throw new Error('Choose a different team name.');
239
+ await this.withTeamLocks([from, to], async () => {
240
+ const sourceExists = await lstat(this.path(from)).then(() => true, error => {
241
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
242
+ throw error;
243
+ });
244
+ const targetExists = await lstat(this.path(to)).then(() => true, error => {
245
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
246
+ throw error;
247
+ });
248
+ // Recover the only partial state possible: the directory move completed,
249
+ // but the record still carries the old team name.
250
+ if (!sourceExists && targetExists) {
251
+ await privateDirectory(this.path(to));
252
+ const completed = await readRecord(this.recordPath(to), this.normalize(to), MAX_BYTES).catch(error => {
253
+ if ((error as Error).message === 'Invalid mailbox format: team mismatch') return undefined;
254
+ throw error;
255
+ });
256
+ if (completed) throw new Error(`Team "${from}" does not exist; "${to}" already exists.`);
257
+ const interrupted = await readRecord(this.recordPath(to), this.normalize(from), MAX_BYTES);
258
+ if (!interrupted) throw new Error(`Mailbox record for team "${to}" is missing; recovery required.`);
259
+ await this.disconnectInactiveMembers(to, interrupted.payload);
260
+ const recovered = this.renameState(interrupted.payload, from, to);
261
+ await publishLocked(this.recordPath(to), interrupted.revision, recovered, this.normalize(to),
262
+ { maxBytes: MAX_BYTES, currentNormalize: this.normalize(from) });
263
+ return;
264
+ }
265
+ if (!sourceExists) throw new Error(`Unknown team "${from}". Use /team list or /team create.`);
266
+ if (targetExists) throw new Error(`Team "${to}" already exists`);
267
+ await privateDirectory(this.path(from));
268
+ const record = await this.readState(from);
269
+ await this.disconnectInactiveMembers(from, record.payload);
270
+ await rename(this.path(from), this.path(to));
271
+ created.delete(this.path(from));
272
+ created.add(this.path(to));
273
+ const renamed = this.renameState(record.payload, from, to);
274
+ await publishLocked(this.recordPath(to), record.revision, renamed, this.normalize(to),
275
+ { maxBytes: MAX_BYTES, currentNormalize: this.normalize(from) });
276
+ });
277
+ }
278
+
279
+ async deleteTeam(team: string): Promise<void> {
280
+ identifier(team);
281
+ await this.withTeamLocks([team], async () => {
282
+ const exists = await lstat(this.path(team)).then(() => true, error => {
283
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
284
+ throw error;
285
+ });
286
+ if (!exists) throw new Error(`Unknown team "${team}". Use /team list or /team create.`);
287
+ await privateDirectory(this.path(team));
288
+ const record = await this.readState(team);
289
+ await this.disconnectInactiveMembers(team, record.payload);
290
+ const tombstone = join(this.root, `.deleted-${team}-${randomUUID()}`);
291
+ await rename(this.path(team), tombstone);
292
+ created.delete(this.path(team));
293
+ await rm(tombstone, { recursive: true, force: true });
294
+ });
295
+ }
296
+
197
297
  async join(team: string, alias: string, session: string, cwd: string): Promise<Membership> {
198
298
  identifier(alias);
199
299
  const member = await this.mutate(team, state => {
@@ -217,9 +317,9 @@ export class Mailbox {
217
317
  return current;
218
318
  }
219
319
 
220
- private async writePresence(member: Membership, status: 'idle' | 'busy' | 'paused'): Promise<void> {
320
+ private async writePresence(member: Membership, status: 'idle' | 'busy' | 'paused', createParent = true): Promise<void> {
221
321
  const text = JSON.stringify({ token: member.token, status, seen: Date.now() } satisfies Presence);
222
- await writeAtomic(this.presencePath(member.team, member.alias), text, 'none');
322
+ await writeAtomic(this.presencePath(member.team, member.alias), text, 'none', createParent);
223
323
  }
224
324
 
225
325
  /** Lock-free heartbeat: touches only this member's own presence file. */
@@ -227,7 +327,9 @@ export class Mailbox {
227
327
  const record = await this.readState(member.team, true);
228
328
  const current = record.payload.members.find(m => m.alias === member.alias && m.token === member.token);
229
329
  if (!current || current.status === 'offline') throw new Error('Membership expired or replaced. Rejoin the team.');
230
- await this.writePresence(member, status);
330
+ // A lifecycle operation can move/delete the team after the lock-free read.
331
+ // Never recreate that old directory from a late heartbeat.
332
+ await this.writePresence(member, status, false);
231
333
  }
232
334
 
233
335
  /**
@@ -276,6 +378,62 @@ export class Mailbox {
276
378
  await this.mutate(member.team, state => { this.owner(state, member); });
277
379
  }
278
380
 
381
+ async removeMember(member: Membership, alias: string): Promise<{ settled: number }> {
382
+ identifier(alias);
383
+ const result = await this.mutate(member.team, state => {
384
+ const actor = this.owner(state, member);
385
+ const target = state.members.find(candidate => candidate.alias === alias);
386
+ if (!target) throw new Error(`Unknown teammate "${alias}"`);
387
+ if (target.alias === actor.alias) throw new Error('Use /team leave instead of removing yourself.');
388
+ if (target.status !== 'offline') throw new Error(`Teammate "${alias}" is active; ask them to leave first.`);
389
+ const affected = state.messages.filter(message =>
390
+ ['pending', 'processing'].includes(message.state) &&
391
+ (message.to === alias || (message.from === alias && message.kind === 'request')));
392
+ for (const message of affected) {
393
+ if (message.kind === 'request' && message.to === alias) {
394
+ this.finish(state, message, {
395
+ outcome: 'interrupted',
396
+ body: `Teammate "${alias}" was removed. Work was not automatically retried. Review before continuing.`,
397
+ files: [], tests: [],
398
+ });
399
+ } else {
400
+ message.state = message.kind === 'request' ? 'interrupted' : 'seen';
401
+ delete message.claim;
402
+ }
403
+ }
404
+ state.members = state.members.filter(candidate => candidate.alias !== alias);
405
+ return { settled: affected.length };
406
+ });
407
+ await unlink(this.presencePath(member.team, alias)).catch(() => {});
408
+ return result;
409
+ }
410
+
411
+ async renameMember(member: Membership, alias: string, nextAlias: string): Promise<Membership> {
412
+ identifier(alias); identifier(nextAlias);
413
+ if (alias === nextAlias) throw new Error('Choose a different teammate alias.');
414
+ const renamed = await this.mutate(member.team, state => {
415
+ const actor = this.owner(state, member);
416
+ const target = state.members.find(candidate => candidate.alias === alias);
417
+ if (!target) throw new Error(`Unknown teammate "${alias}"`);
418
+ if (state.members.some(candidate => candidate.alias === nextAlias)) throw new Error(`Alias "${nextAlias}" already exists.`);
419
+ if (target.alias !== actor.alias && target.status !== 'offline') {
420
+ throw new Error(`Teammate "${alias}" is active; only that session can rename itself.`);
421
+ }
422
+ target.alias = nextAlias;
423
+ if (target.token === actor.token) target.seen = Date.now();
424
+ for (const message of state.messages) {
425
+ if (message.from === alias) message.from = nextAlias;
426
+ if (message.to === alias) message.to = nextAlias;
427
+ }
428
+ return { team: target.team, alias: target.alias, session: target.session, token: target.token };
429
+ });
430
+ if (member.alias === alias) {
431
+ await this.writePresence(renamed, 'idle').catch(() => {});
432
+ }
433
+ await unlink(this.presencePath(member.team, alias)).catch(() => {});
434
+ return renamed;
435
+ }
436
+
279
437
  async leave(member: Membership): Promise<void> {
280
438
  await this.mutate(member.team, state => { this.disconnect(state, this.owner(state, member)); });
281
439
  await unlink(this.presencePath(member.team, member.alias)).catch(() => {});
package/src/store.ts CHANGED
@@ -110,9 +110,8 @@ async function syncDirectory(path: string): Promise<void> {
110
110
  }
111
111
 
112
112
  /** Atomic last-writer-wins write for records without revision history (presence). */
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.
115
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
113
+ export async function writeAtomic(path: string, text: string, durability: Durability = 'full', createParent = true): Promise<void> {
114
+ if (createParent) await mkdir(dirname(path), { recursive: true, mode: 0o700 });
116
115
  const tmp = `${path}.${randomUUID()}.tmp`;
117
116
  const handle = await open(tmp, 'wx', 0o600);
118
117
  try {
@@ -151,6 +150,17 @@ async function acquireLock(lockPath: string) {
151
150
  return await tryLock(lockPath) ?? (() => { throw locked(); })();
152
151
  }
153
152
 
153
+ /** Run one storage operation while holding a caller-chosen private lock. */
154
+ export async function withFileLock<T>(lockPath: string, action: () => Promise<T>, createParent = true): Promise<T> {
155
+ if (createParent) await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
156
+ const lock = await acquireLock(lockPath);
157
+ try { return await action(); }
158
+ finally {
159
+ await lock.close();
160
+ await unlink(lockPath).catch(() => {});
161
+ }
162
+ }
163
+
154
164
  async function pruneRevisions(dir: string, latest: number): Promise<void> {
155
165
  const revisionsDir = join(dir, 'revisions');
156
166
  const names = await readdir(revisionsDir).catch(() => [] as string[]);
@@ -162,6 +172,50 @@ async function pruneRevisions(dir: string, latest: number): Promise<void> {
162
172
  }
163
173
  }
164
174
 
175
+ /**
176
+ * Publication body for callers that already hold the record's lock. It still
177
+ * checks the expected revision, but does not acquire or release a lock itself.
178
+ */
179
+ export async function publishLocked<T>(
180
+ path: string, expectedRevision: number, payload: T, normalize: Normalize<T>,
181
+ options: { maxBytes: number; durability?: Durability; payloadJson?: string; currentNormalize?: Normalize<T> },
182
+ ): Promise<Record<T>> {
183
+ const durability = options.durability ?? 'full';
184
+ // Check the revision before creating a missing parent. A stale writer racing
185
+ // a team deletion must fail rather than recreate an empty ghost directory.
186
+ const current = await readRecord(path, options.currentNormalize ?? normalize, options.maxBytes);
187
+ const revision = current?.revision ?? 0;
188
+ if (revision !== expectedRevision) {
189
+ throw Object.assign(new Error(`Record changed before the write; current revision is ${revision}.`), { code: 'STALE_REVISION' });
190
+ }
191
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
192
+ const next = revision + 1;
193
+ // Supplied by callers that already serialized this exact object; it must
194
+ // equal JSON.stringify(payload). The record stays self-consistent either
195
+ // way, because the hash is taken over the string that gets embedded.
196
+ const payloadJson = options.payloadJson ?? JSON.stringify(payload);
197
+ const text = `{"schemaVersion":1,"revision":${next},"contentHash":"${sha256(payloadJson)}","payload":${payloadJson}}`;
198
+ if (Buffer.byteLength(text) > options.maxBytes) throw new Error('Record size limit exceeded.');
199
+ const historyPath = join(dirname(path), 'revisions', `${next}.json`);
200
+ const previous = await readRecord(historyPath, (raw: string) => envelope<T>(raw), options.maxBytes);
201
+ if (previous && (previous.revision !== next || (previous.payloadJson ?? JSON.stringify(previous.payload)) !== payloadJson)) {
202
+ throw new Error('An interrupted publication owns this revision; explicit recovery is required.');
203
+ }
204
+ if (!previous) await writeAtomic(historyPath, text, durability);
205
+ // Point `path` at the inode already holding the history copy: one write
206
+ // per publication, and the current record shares bytes with its revision.
207
+ const tmp = `${path}.${randomUUID()}.tmp`;
208
+ await link(historyPath, tmp);
209
+ try {
210
+ await rename(tmp, path);
211
+ if (durability === 'full') await syncDirectory(path);
212
+ } finally { await unlink(tmp).catch(() => {}); }
213
+ cache.delete(path);
214
+ counters.publishes++;
215
+ await pruneRevisions(dirname(path), next).catch(() => {});
216
+ return { revision: next, payload, payloadJson };
217
+ }
218
+
165
219
  /**
166
220
  * Compare-and-swap publication. Fails fast with STALE_REVISION when the record
167
221
  * moved since the caller's read, or RECORD_LOCKED while another writer holds
@@ -169,45 +223,25 @@ async function pruneRevisions(dir: string, latest: number): Promise<void> {
169
223
  */
170
224
  export async function publish<T>(
171
225
  path: string, expectedRevision: number, payload: T, normalize: Normalize<T>,
172
- options: { maxBytes: number; durability?: Durability; payloadJson?: string },
226
+ options: { maxBytes: number; durability?: Durability; payloadJson?: string; lockPath?: string },
173
227
  ): Promise<Record<T>> {
174
- const durability = options.durability ?? 'full';
175
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
176
- const lockPath = `${path}.lock`;
177
- const lock = await acquireLock(lockPath);
178
- try {
228
+ const legacyLock = `${path}.lock`;
229
+ const primaryLock = options.lockPath ?? legacyLock;
230
+ // Releases before 0.6.0 lock the record path, while lifecycle-safe writers
231
+ // use the stable external team lock. During a rolling reload both versions
232
+ // can be alive, so new normal publications must intersect both lock sets or
233
+ // two writers can pass the revision check and publish the same next revision.
234
+ return withFileLock(primaryLock, async () => {
235
+ if (primaryLock === legacyLock) return publishLocked(path, expectedRevision, payload, normalize, options);
236
+ // Acquiring an in-directory compatibility lock must not recreate a team
237
+ // that a lifecycle operation deleted while this writer was waiting for the
238
+ // stable lock. Revision-zero creation is the only intentional exception.
179
239
  const current = await readRecord(path, normalize, options.maxBytes);
180
- const revision = current?.revision ?? 0;
181
- if (revision !== expectedRevision) {
182
- throw Object.assign(new Error(`Record changed before the write; current revision is ${revision}.`), { code: 'STALE_REVISION' });
183
- }
184
- const next = revision + 1;
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);
189
- const text = `{"schemaVersion":1,"revision":${next},"contentHash":"${sha256(payloadJson)}","payload":${payloadJson}}`;
190
- if (Buffer.byteLength(text) > options.maxBytes) throw new Error('Record size limit exceeded.');
191
- const historyPath = join(dirname(path), 'revisions', `${next}.json`);
192
- const previous = await readRecord(historyPath, (raw: string) => envelope<T>(raw), options.maxBytes);
193
- if (previous && (previous.revision !== next || (previous.payloadJson ?? JSON.stringify(previous.payload)) !== payloadJson)) {
194
- throw new Error('An interrupted publication owns this revision; explicit recovery is required.');
240
+ if (!current && expectedRevision !== 0) {
241
+ throw Object.assign(new Error('Record changed before the write; current revision is 0.'), { code: 'STALE_REVISION' });
195
242
  }
196
- if (!previous) await writeAtomic(historyPath, text, durability);
197
- // Point `path` at the inode already holding the history copy: one write
198
- // per publication, and the current record shares bytes with its revision.
199
- const tmp = `${path}.${randomUUID()}.tmp`;
200
- await link(historyPath, tmp);
201
- try {
202
- await rename(tmp, path);
203
- if (durability === 'full') await syncDirectory(path);
204
- } finally { await unlink(tmp).catch(() => {}); }
205
- cache.delete(path);
206
- counters.publishes++;
207
- await pruneRevisions(dirname(path), next).catch(() => {});
208
- return { revision: next, payload, payloadJson };
209
- } finally {
210
- await lock.close();
211
- await unlink(lockPath).catch(() => {});
212
- }
243
+ if (!current) await mkdir(dirname(path), { recursive: true, mode: 0o700 });
244
+ return withFileLock(legacyLock,
245
+ () => publishLocked(path, expectedRevision, payload, normalize, options), false);
246
+ });
213
247
  }