@prjct.app/pi-team 0.6.1 → 0.7.0

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/CONTRIBUTING.md +2 -1
  3. package/README.md +23 -178
  4. package/docs/architecture.md +36 -173
  5. package/package.json +10 -4
  6. package/src/commands/team-command.ts +37 -0
  7. package/src/domain/lease.ts +54 -0
  8. package/src/domain/member.ts +58 -0
  9. package/src/domain/message.ts +91 -0
  10. package/src/domain/request.ts +67 -0
  11. package/src/domain/team.ts +71 -0
  12. package/src/dynamic/domain.ts +110 -0
  13. package/src/dynamic/memory.ts +38 -0
  14. package/src/dynamic/panel.ts +155 -0
  15. package/src/dynamic/peer-log.ts +39 -0
  16. package/src/dynamic/runner.ts +196 -0
  17. package/src/dynamic/service.ts +292 -0
  18. package/src/dynamic/store.ts +57 -0
  19. package/src/dynamic/view.ts +21 -0
  20. package/src/dynamic/worker.ts +210 -0
  21. package/src/dynamic/workspace.ts +43 -0
  22. package/src/index.ts +204 -679
  23. package/src/process-identity.ts +68 -0
  24. package/src/runtime/delivery.ts +326 -0
  25. package/src/runtime/membership.ts +212 -0
  26. package/src/runtime/presence.ts +98 -0
  27. package/src/runtime/purge.ts +39 -0
  28. package/src/runtime/reconciler.ts +112 -0
  29. package/src/runtime/requests.ts +353 -0
  30. package/src/runtime/resources.ts +117 -0
  31. package/src/runtime/team-runtime.ts +47 -0
  32. package/src/runtime/team-tool.ts +191 -0
  33. package/src/storage/atomic.ts +347 -0
  34. package/src/storage/inbox-store.ts +290 -0
  35. package/src/storage/lease-store.ts +158 -0
  36. package/src/storage/paths.ts +76 -0
  37. package/src/storage/receipt-store.ts +117 -0
  38. package/src/storage/team-store.ts +190 -0
  39. package/src/supervisor/control-protocol.ts +125 -0
  40. package/src/supervisor/runtime-store.ts +231 -0
  41. package/src/supervisor/shutdown.ts +141 -0
  42. package/src/supervisor/supervisor.ts +657 -0
  43. package/src/supervisor/tmux-adapter.ts +192 -0
  44. package/src/supervisor/worker-bootstrap.ts +43 -0
  45. package/src/supervisor/worker-client.ts +233 -0
  46. package/src/ui/team-dashboard.ts +179 -0
  47. package/src/mailbox.ts +0 -536
  48. package/src/schema.ts +0 -25
  49. package/src/store.ts +0 -247
package/src/mailbox.ts DELETED
@@ -1,536 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { lstat, mkdir, readdir, readFile, rename, rm, unlink } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { Value } from 'typebox/value';
5
- import { ResultSchema, StateSchema } from './schema.ts';
6
- import { envelope, publish, publishLocked, readRecord, readRecordCached, withFileLock, writeAtomic, type Record as StoreRecord } from './store.ts';
7
-
8
- export type Membership = { team: string; alias: string; session: string; token: string };
9
- export type Member = Membership & { cwd: string; pid: number; seen: number; status: 'idle' | 'busy' | 'paused' | 'offline' };
10
- export type Result = { outcome: 'completed' | 'failed' | 'interrupted'; body: string; files: string[]; tests: string[] };
11
- export type Message = {
12
- id: string; team: string; from: string; to: string; subject: string; body: string;
13
- kind: 'request' | 'note' | 'result'; state: 'pending' | 'processing' | 'completed' | 'interrupted' | 'seen';
14
- created: number; rootId: string; parentId?: string; claim?: string; result?: Result;
15
- };
16
- export type Outgoing = { to: string; kind: 'request' | 'note'; subject: string; body: string; parentId?: string };
17
- export type FlowItem = Pick<Message, 'id' | 'from' | 'to' | 'subject' | 'state' | 'created'>;
18
- export type Snapshot = { revision: number; sweepable: boolean; members: Member[]; messages: Message[]; flow: FlowItem[] };
19
- type State = { version: 1; members: Member[]; messages: Message[] };
20
- type Presence = { token: string; status: 'idle' | 'busy' | 'paused'; seen: number };
21
- export const LEASE_MS = 30_000;
22
- const MAX_BYTES = 32_000_000;
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
- }
31
-
32
- export function identifier(value: string): string {
33
- if (!/^[a-z][a-z0-9-]{0,47}$/.test(value)) {
34
- throw new Error('Use 1–48 lowercase letters, digits or hyphens, starting with a letter.');
35
- }
36
- return value;
37
- }
38
-
39
- /**
40
- * Directories known to have been created by this process. Only the `mkdir` is
41
- * skipped: the `lstat` check runs every time, because detecting a directory
42
- * swapped after the fact is the entire point of the check.
43
- */
44
- const created = new Set<string>();
45
-
46
- async function privateDirectory(path: string): Promise<void> {
47
- if (!created.has(path)) await mkdir(path, { mode: 0o700, recursive: true });
48
- const stat = await lstat(path);
49
- if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 ||
50
- (process.getuid && stat.uid !== process.getuid())) {
51
- created.delete(path);
52
- throw new Error(`Unsafe directory: ${path}. Expected a private directory owned by this user.`);
53
- }
54
- created.add(path);
55
- }
56
-
57
- /**
58
- * Team mailbox on top of the single-record store. Reads are lock-free; writes
59
- * compare-and-swap on the record revision and retry against a fresh read, so
60
- * teammates can write concurrently instead of waiting for a team-wide lock.
61
- * Presence lives in per-member files outside the record: heartbeats never
62
- * touch shared state.
63
- */
64
- export class Mailbox {
65
- constructor(readonly root: string) {}
66
-
67
- private path(team: string): string { return join(this.root, identifier(team)); }
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
-
86
- private presencePath(team: string, alias: string): string {
87
- return join(this.path(team), 'presence', `${identifier(alias)}.json`);
88
- }
89
-
90
- /** Parse either an envelope record or a pre-envelope mailbox. */
91
- private parse(raw: string): StoreRecord<State> {
92
- const parsed = parseMailbox(raw);
93
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) &&
94
- (parsed as { schemaVersion?: unknown }).schemaVersion === 1) {
95
- return envelope<State>(raw);
96
- }
97
- // Legacy mailbox without an envelope: accepted once as revision 0 and
98
- // rewritten as an envelope record on the next publication.
99
- if (!Value.Check(StateSchema, parsed)) throw new Error('Invalid mailbox format; preserved for manual recovery');
100
- return { revision: 0, payload: parsed as State };
101
- }
102
-
103
- /** Envelope records plus transparent migration of pre-envelope mailboxes. */
104
- private normalize(team: string) {
105
- return (raw: string): StoreRecord<State> => {
106
- const record = this.parse(raw);
107
- const state = record.payload;
108
- if (!Value.Check(StateSchema, state)) throw new Error('Invalid mailbox format; preserved for manual recovery');
109
- if (state.members.some(m => m.team !== team) || state.messages.some(m => m.team !== team)) {
110
- throw new Error('Invalid mailbox format: team mismatch');
111
- }
112
- return record;
113
- };
114
- }
115
-
116
- private async readState(team: string, cached = false): Promise<StoreRecord<State>> {
117
- const read = cached ? readRecordCached : readRecord;
118
- const record = await read(this.recordPath(team), this.normalize(team), MAX_BYTES);
119
- if (record) return record;
120
- try { await lstat(this.path(team)); }
121
- catch { throw new Error(`Unknown team "${team}". Use /team list or /team create.`); }
122
- throw new Error(`Mailbox record for team "${team}" is missing; recovery required.`);
123
- }
124
-
125
- private async readPresence(team: string): Promise<Map<string, Presence>> {
126
- const names = await readdir(join(this.path(team), 'presence')).catch(() => [] as string[]);
127
- const entries = await Promise.all(names.map(async (name): Promise<[string, Presence] | undefined> => {
128
- if (!/^[a-z][a-z0-9-]{0,47}\.json$/.test(name)) return undefined;
129
- try {
130
- const raw = await readFile(join(this.path(team), 'presence', name), 'utf8');
131
- if (raw.length > 4096) return undefined;
132
- const presence = JSON.parse(raw) as Presence;
133
- if (typeof presence?.seen === 'number' && typeof presence?.token === 'string' &&
134
- ['idle', 'busy', 'paused'].includes(presence?.status)) {
135
- return [name.slice(0, -'.json'.length), presence];
136
- }
137
- } catch { /* A presence file may be replaced or removed mid-read. */ }
138
- return undefined;
139
- }));
140
- return new Map(entries.filter(entry => !!entry));
141
- }
142
-
143
- private alive(member: Member, presence: Map<string, Presence>): boolean {
144
- const current = presence.get(member.alias);
145
- const seen = current?.token === member.token ? current.seen : member.seen;
146
- if (Date.now() - seen >= LEASE_MS) return false;
147
- try { process.kill(member.pid, 0); return true; }
148
- catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; }
149
- }
150
-
151
- private withStatus(member: Member, presence: Map<string, Presence>): Member {
152
- if (member.status === 'offline' || !this.alive(member, presence)) return { ...member, status: 'offline' };
153
- const current = presence.get(member.alias);
154
- return { ...member, status: current?.token === member.token ? current.status : member.status };
155
- }
156
-
157
- /**
158
- * Optimistic mutation: read lock-free, sweep disconnected members, apply the
159
- * action, and compare-and-swap the record. Conflicts retry against a fresh
160
- * read; business errors thrown by the action abort immediately.
161
- */
162
- private async mutate<T>(team: string, action: (state: State) => T): Promise<T> {
163
- await this.prepareRoot();
164
- try { await lstat(this.path(team)); }
165
- catch { throw new Error(`Unknown team "${team}". Use /team list or /team create.`); }
166
- await privateDirectory(this.path(team));
167
- for (const attempt of ATTEMPTS) {
168
- const record = await this.readState(team);
169
- const state = record.payload;
170
- // The parser already produced this for the content hash. A pre-envelope
171
- // record carries none, so that path serializes as before. A mismatch
172
- // could only ever cause one redundant publication, never a lost write:
173
- // `before` comes from the pre-action object and `after` from the
174
- // post-action one, so they cannot coincide by accident.
175
- const before = record.payloadJson ?? JSON.stringify(state);
176
- const presence = await this.readPresence(team);
177
- const swept = state.members.filter(member => member.status !== 'offline' && !this.alive(member, presence));
178
- for (const member of swept) this.disconnect(state, member);
179
- const result = action(state);
180
- const after = JSON.stringify(state);
181
- if (before === after) return result;
182
- if (!Value.Check(StateSchema, state)) throw new Error('Invalid mailbox format; refusing to write');
183
- try {
184
- await publish(this.recordPath(team), record.revision, state, this.normalize(team), this.recordOptions(team, after));
185
- await Promise.all(swept.map(member => unlink(this.presencePath(team, member.alias)).catch(() => {})));
186
- return result;
187
- } catch (error) {
188
- const code = (error as { code?: string }).code;
189
- if (code !== 'STALE_REVISION' && code !== 'RECORD_LOCKED') throw error;
190
- await new Promise(resolve => setTimeout(resolve, 5 + Math.random() * Math.min(95, 5 + attempt * 5)));
191
- }
192
- }
193
- throw new Error('Mailbox is busy; try again.');
194
- }
195
-
196
- async create(team: string): Promise<void> {
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
- });
213
- }
214
-
215
- async teams(): Promise<string[]> {
216
- await this.prepareRoot();
217
- const entries = await readdir(this.root, { withFileTypes: true });
218
- return entries.filter(e => e.isDirectory() && /^[a-z][a-z0-9-]{0,47}$/.test(e.name)).map(e => e.name).sort();
219
- }
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
-
297
- async join(team: string, alias: string, session: string, cwd: string): Promise<Membership> {
298
- identifier(alias);
299
- const member = await this.mutate(team, state => {
300
- const existing = state.members.find(m => m.alias === alias);
301
- if (existing && existing.status !== 'offline' && Date.now() - existing.seen < LEASE_MS) {
302
- throw new Error(`Alias "${alias}" already in use. Choose another or leave from its terminal.`);
303
- }
304
- if (!existing && state.members.length >= 100) throw new Error('Team member limit reached (100)');
305
- const joined: Member = { team, alias, session, token: randomUUID(), cwd, pid: process.pid, seen: Date.now(), status: 'idle' };
306
- state.members = state.members.filter(m => m.alias !== alias);
307
- state.members.push(joined);
308
- return joined;
309
- });
310
- await this.writePresence(member, 'idle');
311
- return member;
312
- }
313
-
314
- private owner(state: State, member: Membership): Member {
315
- const current = state.members.find(m => m.alias === member.alias && m.token === member.token);
316
- if (!current || current.status === 'offline') throw new Error('Membership expired or replaced. Rejoin the team.');
317
- return current;
318
- }
319
-
320
- private async writePresence(member: Membership, status: 'idle' | 'busy' | 'paused', createParent = true): Promise<void> {
321
- const text = JSON.stringify({ token: member.token, status, seen: Date.now() } satisfies Presence);
322
- await writeAtomic(this.presencePath(member.team, member.alias), text, 'none', createParent);
323
- }
324
-
325
- /** Lock-free heartbeat: touches only this member's own presence file. */
326
- async heartbeat(member: Membership, status: 'idle' | 'busy' | 'paused'): Promise<void> {
327
- const record = await this.readState(member.team, true);
328
- const current = record.payload.members.find(m => m.alias === member.alias && m.token === member.token);
329
- if (!current || current.status === 'offline') throw new Error('Membership expired or replaced. Rejoin the team.');
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);
333
- }
334
-
335
- /**
336
- * Lock-free consistent view of the record with presence-based statuses.
337
- *
338
- * The cached read returns a shared record, and the message objects below are
339
- * the record's own, not copies. Never mutate them: it would corrupt both the
340
- * process-wide cache and the `payloadJson` taken alongside it. `mutate` is
341
- * safe because it reads uncached.
342
- */
343
- async snapshot(member: Membership): Promise<Snapshot> {
344
- const record = await this.readState(member.team, true);
345
- this.owner(record.payload, member);
346
- const presence = await this.readPresence(member.team);
347
- // Members the record still counts as connected but whose session is gone.
348
- // Collected here so liveness is probed once per member, not twice.
349
- const stale = record.payload.members.filter(m => m.status !== 'offline' && !this.alive(m, presence));
350
- return {
351
- revision: record.revision,
352
- // A sweep only has an observable effect when a dead member still holds a
353
- // claim: `disconnect` interrupts it so its requester gets a result.
354
- // Members are never removed from the record, so "someone is offline" is
355
- // permanently true once anyone leaves and cannot gate the sweep.
356
- sweepable: stale.some(dead => record.payload.messages.some(
357
- m => m.state === 'processing' && m.claim === dead.token && m.to === dead.alias)),
358
- members: record.payload.members.map(m => this.withStatus(m, presence)),
359
- messages: record.payload.messages.filter(m => m.from === member.alias || m.to === member.alias),
360
- // Expose only the metadata needed to understand team-wide request flow;
361
- // message bodies remain limited to the sender and recipient.
362
- flow: record.payload.messages
363
- .filter(m => m.kind === 'request' && (m.state === 'pending' || m.state === 'processing'))
364
- .map(({ id, from, to, subject, state, created }) => ({ id, from, to, subject, state, created })),
365
- };
366
- }
367
-
368
- async members(member: Membership): Promise<Member[]> {
369
- return (await this.snapshot(member)).members;
370
- }
371
-
372
- async history(member: Membership): Promise<Message[]> {
373
- return (await this.snapshot(member)).messages;
374
- }
375
-
376
- /** Sweep disconnected members (interrupting their claimed work) on demand. */
377
- async sweep(member: Membership): Promise<void> {
378
- await this.mutate(member.team, state => { this.owner(state, member); });
379
- }
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
-
437
- async leave(member: Membership): Promise<void> {
438
- await this.mutate(member.team, state => { this.disconnect(state, this.owner(state, member)); });
439
- await unlink(this.presencePath(member.team, member.alias)).catch(() => {});
440
- }
441
-
442
- async send(member: Membership, input: Outgoing): Promise<Message> {
443
- identifier(input.to);
444
- if (!['request', 'note'].includes(input.kind)) throw new Error('Invalid message kind');
445
- if (!input.subject.trim() || input.subject.length > 160 || !input.body.trim()) throw new Error('Subject and body required (subject up to 160 characters)');
446
- if (Buffer.byteLength(input.body, 'utf8') > 16_000) throw new Error('Message too large (maximum 16 KB)');
447
- if (Buffer.byteLength(JSON.stringify(input)) > 20_000) throw new Error('Serialized message too large (maximum 20 KB)');
448
- return this.mutate(member.team, state => {
449
- this.owner(state, member);
450
- if (input.to === member.alias) throw new Error('Cannot send a message to yourself');
451
- if (!state.members.some(m => m.alias === input.to)) throw new Error(`Unknown teammate "${input.to}"`);
452
- const parent = input.parentId ? state.messages.find(m => m.id === input.parentId && (m.to === member.alias || m.from === member.alias)) : undefined;
453
- if (input.parentId && !parent) throw new Error('Unknown parent message');
454
- if (input.kind === 'request' && parent?.result && parent.result.outcome !== 'completed') throw new Error('Ask the user before starting more work from a failed or interrupted result.');
455
- if (parent && state.messages.filter(m => m.rootId === parent.rootId && m.kind !== 'result').length >= 8) throw new Error('Conversation limit reached. Ask the user to continue.');
456
- if (state.messages.some(m => m.from === member.alias && m.to === input.to && m.subject === input.subject && m.body === input.body && Date.now() - m.created < 60_000)) throw new Error('Duplicate message within one minute; do not resend.');
457
- const unsettled = state.messages.filter(m => ['pending', 'processing'].includes(m.state));
458
- // Admitted deliveries keep their slot until settled (including a released
459
- // claim). Every outstanding request also reserves its sender's result slot.
460
- const occupiedSlots = (alias: string) => unsettled.filter(m => m.to === alias || (m.kind === 'request' && m.from === alias)).length;
461
- if (occupiedSlots(input.to) >= 50) throw new Error('Recipient inbox full (including reserved results)');
462
- if (input.kind === 'request' && occupiedSlots(member.alias) >= 50) throw new Error('Sender inbox full; no room to reserve the automatic result');
463
- // Reserve room for one automatic result per outstanding request.
464
- const reserved = unsettled.filter(m => m.kind === 'request').length;
465
- if (state.messages.length + reserved + (input.kind === 'request' ? 2 : 1) > 500) throw new Error('Team history full (500 records). Create a new team.');
466
- const id = randomUUID();
467
- const message: Message = {
468
- id, team: member.team, from: member.alias, to: input.to, subject: input.subject,
469
- body: input.body, kind: input.kind, state: 'pending', created: Date.now(), rootId: parent?.rootId ?? id, parentId: parent?.id,
470
- };
471
- state.messages.push(message);
472
- return message;
473
- });
474
- }
475
-
476
- async notes(member: Membership): Promise<Message[]> {
477
- return this.mutate(member.team, state => {
478
- this.owner(state, member);
479
- const notes = state.messages.filter(m => m.to === member.alias && m.state === 'pending' && m.kind === 'note');
480
- for (const note of notes) note.state = 'seen';
481
- return notes;
482
- });
483
- }
484
-
485
- async receive(member: Membership, ready: boolean): Promise<Message | undefined> {
486
- if (!ready) return;
487
- return this.mutate(member.team, state => {
488
- this.owner(state, member);
489
- if (state.messages.some(m => m.to === member.alias && m.state === 'processing')) return;
490
- const message = state.messages.find(m => m.to === member.alias && m.state === 'pending' && m.kind !== 'note');
491
- if (!message) return;
492
- message.state = 'processing';
493
- message.claim = member.token;
494
- return message;
495
- });
496
- }
497
-
498
- async release(member: Membership, id: string): Promise<void> {
499
- await this.mutate(member.team, state => {
500
- this.owner(state, member);
501
- const message = state.messages.find(m => m.id === id && m.claim === member.token && m.state === 'processing');
502
- if (!message) throw new Error('Message not claimed by this session');
503
- message.state = 'pending'; delete message.claim;
504
- });
505
- }
506
-
507
- async complete(member: Membership, id: string, result: Result): Promise<void> {
508
- if (!Value.Check(ResultSchema, result) || Buffer.byteLength(JSON.stringify(result)) > 32000) throw new Error('Invalid or oversized result report');
509
- await this.mutate(member.team, state => {
510
- this.owner(state, member);
511
- const message = state.messages.find(m => m.id === id && m.to === member.alias && m.claim === member.token);
512
- if (!message) throw new Error('Message not claimed by this session');
513
- if (message.state !== 'processing') return;
514
- this.finish(state, message, result);
515
- });
516
- }
517
-
518
- private finish(state: State, message: Message, result: Result): void {
519
- message.state = result.outcome === 'interrupted' ? 'interrupted' : 'completed';
520
- if (message.kind !== 'request') return;
521
- state.messages.push({
522
- id: randomUUID(), team: message.team, from: message.to, to: message.from,
523
- subject: message.subject, body: result.body, kind: 'result', state: 'pending',
524
- created: Date.now(), rootId: message.rootId, parentId: message.id, result,
525
- });
526
- }
527
-
528
- private disconnect(state: State, member: Member): void {
529
- member.status = 'offline';
530
- for (const message of state.messages) {
531
- if (message.to === member.alias && message.claim === member.token && message.state === 'processing') {
532
- this.finish(state, message, { outcome: 'interrupted', body: 'Session disconnected. Work may be partially applied and was not automatically retried. Review before continuing.', files: [], tests: [] });
533
- }
534
- }
535
- }
536
- }
package/src/schema.ts DELETED
@@ -1,25 +0,0 @@
1
- import { Type } from 'typebox';
2
- const enumOf = <T extends string>(...values: T[]) => Type.Union(values.map(value => Type.Literal(value)));
3
- const name = Type.String({ pattern: '^[a-z][a-z0-9-]{0,47}$' });
4
- const id = Type.String({ minLength: 1, maxLength: 128 });
5
- export const ResultSchema = Type.Object({
6
- outcome: enumOf('completed', 'failed', 'interrupted'),
7
- body: Type.String({ maxLength: 16000 }),
8
- files: Type.Array(Type.String({ maxLength: 4096 }), { maxItems: 50 }),
9
- tests: Type.Array(Type.String({ maxLength: 1024 }), { maxItems: 50 }),
10
- });
11
- export const StateSchema = Type.Object({
12
- version: Type.Literal(1),
13
- members: Type.Array(Type.Object({
14
- team: name, alias: name, session: id, token: id,
15
- cwd: Type.String({ maxLength: 4096 }), pid: Type.Integer({ minimum: 1 }),
16
- seen: Type.Number({ minimum: 0 }), status: enumOf('idle', 'busy', 'paused', 'offline'),
17
- }), { maxItems: 100 }),
18
- messages: Type.Array(Type.Object({
19
- id, team: name, from: name, to: name,
20
- subject: Type.String({ minLength: 1, maxLength: 160 }), body: Type.String({ maxLength: 16000 }),
21
- kind: enumOf('request', 'note', 'result'), state: enumOf('pending', 'processing', 'completed', 'interrupted', 'seen'),
22
- created: Type.Number({ minimum: 0 }), rootId: id, parentId: Type.Optional(id), claim: Type.Optional(id),
23
- result: Type.Optional(ResultSchema),
24
- }), { maxItems: 500 }),
25
- });