dsh-live-teams 0.1.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 (69) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +11 -0
  3. package/README.md +85 -0
  4. package/cordis.patch.yml +25 -0
  5. package/lib/binding.d.ts +18 -0
  6. package/lib/binding.js +42 -0
  7. package/lib/changed-paths.d.ts +26 -0
  8. package/lib/changed-paths.js +69 -0
  9. package/lib/client.js +6753 -0
  10. package/lib/command-queue.d.ts +60 -0
  11. package/lib/command-queue.js +185 -0
  12. package/lib/compatibility.js +109 -0
  13. package/lib/context-provider.d.ts +110 -0
  14. package/lib/context-provider.js +249 -0
  15. package/lib/dispatch.d.ts +174 -0
  16. package/lib/dispatch.js +624 -0
  17. package/lib/errors.d.ts +36 -0
  18. package/lib/errors.js +103 -0
  19. package/lib/git-artifacts.d.ts +50 -0
  20. package/lib/git-artifacts.js +242 -0
  21. package/lib/index.d.ts +14 -0
  22. package/lib/index.js +14 -0
  23. package/lib/mailbox.d.ts +274 -0
  24. package/lib/mailbox.js +721 -0
  25. package/lib/member-tools.d.ts +57 -0
  26. package/lib/member-tools.js +1265 -0
  27. package/lib/migrations.d.ts +17 -0
  28. package/lib/migrations.js +47 -0
  29. package/lib/plugin.d.ts +106 -0
  30. package/lib/plugin.js +1003 -0
  31. package/lib/roles.d.ts +35 -0
  32. package/lib/roles.js +284 -0
  33. package/lib/routes.d.ts +586 -0
  34. package/lib/routes.js +2816 -0
  35. package/lib/scope.d.ts +62 -0
  36. package/lib/scope.js +133 -0
  37. package/lib/session-bridge.d.ts +76 -0
  38. package/lib/session-bridge.js +147 -0
  39. package/lib/session-title.js +35 -0
  40. package/lib/storage.d.ts +9 -0
  41. package/lib/storage.js +65 -0
  42. package/lib/task-store.d.ts +729 -0
  43. package/lib/task-store.js +2205 -0
  44. package/lib/team-store.d.ts +216 -0
  45. package/lib/team-store.js +765 -0
  46. package/lib/tree-snapshot.d.ts +28 -0
  47. package/lib/tree-snapshot.js +80 -0
  48. package/lib/types/client/TeamView.d.ts +26 -0
  49. package/lib/types/client/TeamView.dom.test.d.ts +1 -0
  50. package/lib/types/client/api.d.ts +522 -0
  51. package/lib/types/client/api.test.d.ts +1 -0
  52. package/lib/types/client/attention.d.ts +65 -0
  53. package/lib/types/client/attention.test.d.ts +1 -0
  54. package/lib/types/client/index.d.ts +31 -0
  55. package/lib/types/client/locales.d.ts +577 -0
  56. package/lib/types/client/member-name.d.ts +14 -0
  57. package/lib/types/client/member-name.test.d.ts +1 -0
  58. package/lib/types/client/roster.d.ts +26 -0
  59. package/lib/types/client/roster.test.d.ts +1 -0
  60. package/lib/types/client/styles.d.ts +3 -0
  61. package/package.json +104 -0
  62. package/roles/builder.md +40 -0
  63. package/roles/delegate.md +36 -0
  64. package/roles/lead.md +46 -0
  65. package/roles/oracle.md +36 -0
  66. package/roles/researcher.md +37 -0
  67. package/roles/reviewer.md +45 -0
  68. package/roles/scout.md +36 -0
  69. package/roles/verifier.md +36 -0
@@ -0,0 +1,765 @@
1
+ import { ERROR_CODES, LiveTeamsError } from "./errors.js";
2
+ import { atomicWriteJson, clone, fileExists, readJson, withProcessLock } from "./storage.js";
3
+ import path from "node:path";
4
+ import { promises, readFileSync, realpathSync } from "node:fs";
5
+ //#region src/team-store.ts
6
+ /**
7
+ * Replace one team id wherever it appears as a `teamId`, at any depth.
8
+ *
9
+ * Records repeat the id inside their own objects (`tasks[].teamId`,
10
+ * `messages[].teamId`, …) and the read paths validate each record, so a shallow
11
+ * rewrite is not enough.
12
+ */
13
+ function restampTeamId(value, from, to) {
14
+ if (Array.isArray(value)) {
15
+ let changed = false;
16
+ const next = value.map((entry) => {
17
+ const result = restampTeamId(entry, from, to);
18
+ if (result.changed) changed = true;
19
+ return result.value;
20
+ });
21
+ return changed ? {
22
+ value: next,
23
+ changed: true
24
+ } : {
25
+ value,
26
+ changed: false
27
+ };
28
+ }
29
+ if (typeof value === "string") {
30
+ const marker = `${path.sep}teams${path.sep}${from}${path.sep}`;
31
+ return value.includes(marker) ? {
32
+ value: value.split(marker).join(`${path.sep}teams${path.sep}${to}${path.sep}`),
33
+ changed: true
34
+ } : {
35
+ value,
36
+ changed: false
37
+ };
38
+ }
39
+ if (value === null || typeof value !== "object") return {
40
+ value,
41
+ changed: false
42
+ };
43
+ let changed = false;
44
+ const next = {};
45
+ for (const [key, entry] of Object.entries(value)) {
46
+ if (key === "teamId" && entry === from) {
47
+ next[key] = to;
48
+ changed = true;
49
+ continue;
50
+ }
51
+ const result = restampTeamId(entry, from, to);
52
+ if (result.changed) changed = true;
53
+ next[key] = result.value;
54
+ }
55
+ return changed ? {
56
+ value: next,
57
+ changed: true
58
+ } : {
59
+ value,
60
+ changed: false
61
+ };
62
+ }
63
+ /**
64
+ * Re-stamp every sibling state file an adopted team directory carries.
65
+ *
66
+ * Adoption moves `teams/<candidate>` to `teams/<derived>`, and each store
67
+ * validates that the `teamId` inside its own files matches the team it was
68
+ * opened for. Re-stamping only `team.json` therefore leaves every other store
69
+ * unreadable. Observed live: after adopting `076467b6-…` as
70
+ * `live-teams-workspace`, `tasks.json`, `messages.json`, `deliveries.json`,
71
+ * `attempts.json`, `submissions.json`, `reviews.json` and `commands.json` still
72
+ * named the old id, so the Tasks and Messages panels answered
73
+ * `503 live-teams state unavailable` on every read.
74
+ *
75
+ * Preparation and writing are separate on purpose: a file that cannot be read
76
+ * refuses the adoption *before* anything is written, so a failed adoption never
77
+ * leaves a half-renamed candidate behind.
78
+ */
79
+ async function prepareRestampedTeamState(directory, from, to) {
80
+ const names = await promises.readdir(directory);
81
+ const prepared = [];
82
+ for (const name of names.filter((entry) => entry.endsWith(".json") && entry !== "team.json")) {
83
+ let raw;
84
+ try {
85
+ raw = await readJson(path.join(directory, name));
86
+ } catch (error) {
87
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot adopt a team whose ${name} is unreadable`, {
88
+ auditDetail: `invalid adoption state file ${name}`,
89
+ cause: error
90
+ });
91
+ }
92
+ const restamped = restampTeamId(raw, from, to);
93
+ if (restamped.changed) prepared.push({
94
+ file: path.join(directory, name),
95
+ value: restamped.value
96
+ });
97
+ }
98
+ return prepared;
99
+ }
100
+ /**
101
+ * Names the transport and the product already use for actors that are not members. A member with one
102
+ * of these ids would send mail *as* that actor — `human` is trusted by the mailbox as a human
103
+ * instruction — so the store refuses to persist such a member at all. The check lives here, not on
104
+ * the HTTP boundary, because a route parser protects one door while the store protects every caller.
105
+ */
106
+ const RESERVED_ACTOR_NAMES = [
107
+ "human",
108
+ "scheduler",
109
+ "team",
110
+ "system",
111
+ "plugin",
112
+ "reconciler",
113
+ "mailbox",
114
+ "live-teams",
115
+ "live_teams"
116
+ ];
117
+ function isReservedActorName(memberId) {
118
+ return RESERVED_ACTOR_NAMES.includes(memberId.trim().toLowerCase());
119
+ }
120
+ /**
121
+ * Who leads this team: the recorded lead when it is still a member, otherwise the single member
122
+ * holding the lead role, otherwise nobody. One definition, because the roster a member is shown and
123
+ * the gate that admits its calls must agree — the expression used to be inlined in a dozen places,
124
+ * and the first change to the rule would have desynchronised them (external review, finding 7).
125
+ */
126
+ function resolveLeadMemberId(team) {
127
+ if (team.leadMemberId !== void 0 && team.members.some((member) => member.memberId === team.leadMemberId)) return team.leadMemberId;
128
+ const leads = team.members.filter((member) => member.role === "lead");
129
+ return leads.length === 1 ? leads[0]?.memberId : void 0;
130
+ }
131
+ const TEAM_ROOT = ".dsh-live-teams";
132
+ const DISABLED_MARKER = "disabled";
133
+ function canonicalWorkspacePath(value) {
134
+ const resolved = path.resolve(value);
135
+ try {
136
+ return realpathSync.native(resolved);
137
+ } catch {
138
+ return resolved;
139
+ }
140
+ }
141
+ function candidateId(value) {
142
+ return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
143
+ }
144
+ function objectRecord(value) {
145
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
146
+ }
147
+ function safeAdoptionPath(root, candidate) {
148
+ const resolvedRoot = path.resolve(root);
149
+ return path.resolve(candidate).startsWith(`${resolvedRoot}${path.sep}`);
150
+ }
151
+ function adoptionState(value) {
152
+ const candidate = objectRecord(value);
153
+ if (candidate === void 0 || !Number.isInteger(candidate.schemaVersion) || Number(candidate.schemaVersion) > 1 || !Number.isInteger(candidate.revision) || typeof candidate.teamId !== "string" || candidate.teamId.length === 0 || typeof candidate.workspaceId !== "string" || candidate.workspaceId.length === 0 || typeof candidate.workspacePath !== "string" || candidate.workspacePath.length === 0 || !Array.isArray(candidate.members) || !Array.isArray(candidate.archivedMemberIds) || !Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.updatedAt)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid candidate state");
154
+ const members = [];
155
+ for (const raw of candidate.members) {
156
+ const member = objectRecord(raw);
157
+ if (member === void 0 || typeof member.memberId !== "string" || member.memberId.length === 0 || typeof member.displayName !== "string" || member.displayName.length === 0 || typeof member.role !== "string" || member.role.length === 0 || typeof member.availability !== "string" || member.availability.length === 0 || member.sessionId !== void 0 && (typeof member.sessionId !== "string" || member.sessionId.length === 0) || !Number.isInteger(member.bindingGeneration) || Number(member.bindingGeneration) < 0 || !Number.isInteger(member.contractRevision) || Number(member.contractRevision) < 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid candidate member");
158
+ members.push(member);
159
+ }
160
+ if (!candidate.archivedMemberIds.every((id) => typeof id === "string" && id.length > 0)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid archived member list");
161
+ return candidate;
162
+ }
163
+ async function adoptionDirectoryOccupied(directory) {
164
+ try {
165
+ if (adoptionState(await readJson(path.join(directory, "team.json"))).members.length > 0) return {
166
+ hasMembers: true,
167
+ hasOtherAudit: true
168
+ };
169
+ let events = [];
170
+ try {
171
+ events = (await promises.readFile(path.join(directory, "events.jsonl"), "utf8")).split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
172
+ } catch (error) {
173
+ if (error.code !== "ENOENT") return {
174
+ hasMembers: true,
175
+ hasOtherAudit: true
176
+ };
177
+ }
178
+ return {
179
+ hasMembers: false,
180
+ hasOtherAudit: events.some((event) => {
181
+ const kind = objectRecord(event)?.kind;
182
+ return kind !== "team/enabled" && kind !== "team/disabled";
183
+ })
184
+ };
185
+ } catch {
186
+ return {
187
+ hasMembers: true,
188
+ hasOtherAudit: true
189
+ };
190
+ }
191
+ }
192
+ var TeamStore = class {
193
+ defaults;
194
+ locations = /* @__PURE__ */ new Map();
195
+ constructor(defaults = {}) {
196
+ this.defaults = defaults;
197
+ }
198
+ async open(request = {}) {
199
+ const workspacePath = request.workspacePath ?? this.defaults.workspacePath;
200
+ const workspaceId = request.workspaceId ?? this.defaults.workspaceId;
201
+ if (!workspacePath || !workspaceId) throw new TypeError("workspacePath and workspaceId are required");
202
+ const teamId = request.teamId ?? this.defaults.teamId ?? workspaceId;
203
+ const initialName = request.name ?? this.defaults.name;
204
+ const location = this.location(workspacePath, workspaceId, teamId);
205
+ await withProcessLock(location.file, async () => {
206
+ await promises.mkdir(location.directory, { recursive: true });
207
+ if (!await fileExists(location.file)) {
208
+ const now = Date.now();
209
+ await atomicWriteJson(location.file, {
210
+ schemaVersion: 1,
211
+ revision: 0,
212
+ teamId,
213
+ workspaceId,
214
+ workspacePath: path.resolve(workspacePath),
215
+ ...typeof initialName === "string" && initialName.length > 0 ? { name: initialName } : {},
216
+ members: [],
217
+ archivedMemberIds: [],
218
+ createdAt: now,
219
+ updatedAt: now
220
+ });
221
+ }
222
+ await this.read(location);
223
+ });
224
+ this.locations.set(teamId, location);
225
+ return this.read(location);
226
+ }
227
+ isDisabledSync(teamId) {
228
+ try {
229
+ readFileSync(path.join(this.requireLocation(teamId).directory, DISABLED_MARKER), "utf8");
230
+ return true;
231
+ } catch (error) {
232
+ if (error.code === "ENOENT") return false;
233
+ throw error;
234
+ }
235
+ }
236
+ async readExisting(request = {}) {
237
+ const workspacePath = request.workspacePath ?? this.defaults.workspacePath;
238
+ const workspaceId = request.workspaceId ?? this.defaults.workspaceId;
239
+ if (!workspacePath || !workspaceId) throw new TypeError("workspacePath and workspaceId are required");
240
+ const teamId = request.teamId ?? this.defaults.teamId ?? workspaceId;
241
+ const location = this.location(workspacePath, workspaceId, teamId);
242
+ if (!await fileExists(location.file)) return void 0;
243
+ this.locations.set(teamId, location);
244
+ return this.read(location);
245
+ }
246
+ async isDisabled(teamId) {
247
+ return fileExists(path.join(this.requireLocation(teamId).directory, DISABLED_MARKER));
248
+ }
249
+ /** Discover old team directories in this workspace without creating state. */
250
+ async listAdoptionCandidates(request) {
251
+ const teamsDirectory = path.resolve(request.workspacePath, TEAM_ROOT, "teams");
252
+ let entries;
253
+ try {
254
+ entries = await promises.readdir(teamsDirectory, { withFileTypes: true });
255
+ } catch (error) {
256
+ if (error.code === "ENOENT") return [];
257
+ throw error;
258
+ }
259
+ const candidates = [];
260
+ for (const entry of entries) {
261
+ if (!entry.isDirectory() || entry.name.startsWith(`${request.derivedTeamId}.`) || !candidateId(entry.name) || entry.name === request.derivedTeamId) continue;
262
+ const directory = path.join(teamsDirectory, entry.name);
263
+ try {
264
+ const checked = adoptionState(await readJson(path.join(directory, "team.json")));
265
+ candidates.push({
266
+ teamId: entry.name,
267
+ name: typeof checked.name === "string" && checked.name.trim().length > 0 ? checked.name.trim() : entry.name,
268
+ memberCount: checked.members.length,
269
+ updatedAt: checked.updatedAt
270
+ });
271
+ } catch {
272
+ candidates.push({
273
+ teamId: entry.name,
274
+ usable: false,
275
+ reason: "invalid state"
276
+ });
277
+ }
278
+ }
279
+ return candidates.sort((left, right) => {
280
+ const leftUsable = left.usable !== false;
281
+ if (leftUsable !== (right.usable !== false)) return leftUsable ? -1 : 1;
282
+ const memberOrder = (right.memberCount ?? -1) - (left.memberCount ?? -1);
283
+ if (memberOrder !== 0) return memberOrder;
284
+ const updatedOrder = (right.updatedAt ?? -1) - (left.updatedAt ?? -1);
285
+ if (updatedOrder !== 0) return updatedOrder;
286
+ return left.teamId < right.teamId ? -1 : left.teamId > right.teamId ? 1 : 0;
287
+ });
288
+ }
289
+ /** Adopt one old team into the workspace-derived directory. */
290
+ async adoptTeam(request) {
291
+ if (!candidateId(request.candidateTeamId) || !candidateId(request.derivedTeamId) || request.candidateTeamId === request.derivedTeamId) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "invalid request", { auditDetail: "invalid adoption path" });
292
+ const canonicalWorkspace = canonicalWorkspacePath(request.workspacePath);
293
+ const teamsDirectory = path.join(canonicalWorkspace, TEAM_ROOT, "teams");
294
+ const candidateDirectory = path.join(teamsDirectory, request.candidateTeamId);
295
+ const derivedDirectory = path.join(teamsDirectory, request.derivedTeamId);
296
+ if (!safeAdoptionPath(teamsDirectory, candidateDirectory) || !safeAdoptionPath(teamsDirectory, derivedDirectory)) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "invalid request", { auditDetail: "invalid adoption path" });
297
+ let candidateRealPath;
298
+ try {
299
+ candidateRealPath = realpathSync.native(candidateDirectory);
300
+ } catch (error) {
301
+ throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "invalid request", {
302
+ auditDetail: "invalid adoption path",
303
+ cause: error
304
+ });
305
+ }
306
+ if (!safeAdoptionPath(realpathSync.native(teamsDirectory), candidateRealPath)) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "invalid request", { auditDetail: "invalid adoption path" });
307
+ let candidateRaw;
308
+ try {
309
+ candidateRaw = await readJson(path.join(candidateDirectory, "team.json"));
310
+ } catch (error) {
311
+ throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "invalid request", {
312
+ auditDetail: "invalid candidate state",
313
+ cause: error
314
+ });
315
+ }
316
+ const candidate = adoptionState(candidateRaw);
317
+ if (await fileExists(derivedDirectory)) {
318
+ const occupied = await adoptionDirectoryOccupied(derivedDirectory);
319
+ if (occupied.hasMembers || occupied.hasOtherAudit) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "live-teams derived team already exists", { auditDetail: "derived team already exists" });
320
+ if (!request.replaceEmptyDerived) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, "live-teams derived team already exists", { auditDetail: "derived team already exists" });
321
+ const replaced = `${derivedDirectory}.${Date.now()}.replaced`;
322
+ await promises.rename(derivedDirectory, replaced);
323
+ }
324
+ const rewritten = {
325
+ ...candidate,
326
+ teamId: request.derivedTeamId,
327
+ workspaceId: request.workspaceId,
328
+ workspacePath: canonicalWorkspace
329
+ };
330
+ const restamped = await prepareRestampedTeamState(candidateDirectory, request.candidateTeamId, request.derivedTeamId);
331
+ await atomicWriteJson(path.join(candidateDirectory, "team.json"), rewritten);
332
+ for (const entry of restamped) await atomicWriteJson(entry.file, entry.value);
333
+ await promises.rename(candidateDirectory, derivedDirectory);
334
+ await promises.unlink(path.join(derivedDirectory, DISABLED_MARKER)).catch((error) => {
335
+ if (error.code !== "ENOENT") throw error;
336
+ });
337
+ const location = this.location(canonicalWorkspace, request.workspaceId, request.derivedTeamId);
338
+ this.locations.set(request.derivedTeamId, location);
339
+ await this.appendEvent(request.derivedTeamId, {
340
+ kind: "team/adopted",
341
+ teamId: request.derivedTeamId,
342
+ fromTeamId: request.candidateTeamId,
343
+ name: typeof candidate.name === "string" && candidate.name.trim().length > 0 ? candidate.name.trim() : request.candidateTeamId,
344
+ memberCount: candidate.members.length,
345
+ at: Date.now(),
346
+ actor: request.actor ?? "human:desktop"
347
+ });
348
+ return {
349
+ state: rewritten,
350
+ adoptedFrom: request.candidateTeamId
351
+ };
352
+ }
353
+ /**
354
+ * Adopt the workspace's own team when it sits under another workspace id.
355
+ *
356
+ * The registry mints a new id whenever an entry is re-created, and the team directory is
357
+ * named by id — so after such an event the panel showed an empty team while the real one
358
+ * (members, tasks, journal) sat right next to it, and only a human clicking "adopt" could
359
+ * bring it back. The canonical path is the stable identity here; the id is a registry
360
+ * variable, so it is adopted rather than treated as a reason to refuse.
361
+ *
362
+ * Deliberately conservative: only one usable candidate, only if it has members, only if it
363
+ * declares this path, and never when the current directory already holds a live team. An
364
+ * ambiguous set stays a human decision — the panel already offers the choice.
365
+ */
366
+ async adoptByPath(request) {
367
+ const canonical = canonicalWorkspacePath(request.workspacePath);
368
+ const candidates = (await this.listAdoptionCandidates({
369
+ workspacePath: request.workspacePath,
370
+ derivedTeamId: request.teamId
371
+ })).filter((candidate) => candidate.usable !== false && (candidate.memberCount ?? 0) > 0);
372
+ if (candidates.length !== 1) return void 0;
373
+ const candidate = candidates[0];
374
+ try {
375
+ const declared = await readJson(path.join(canonical, TEAM_ROOT, "teams", candidate.teamId, "team.json"));
376
+ const declaredPath = typeof declared === "object" && declared !== null ? declared.workspacePath : void 0;
377
+ if (typeof declaredPath === "string" && canonicalWorkspacePath(declaredPath) !== canonical) return void 0;
378
+ } catch {
379
+ return;
380
+ }
381
+ try {
382
+ return {
383
+ adoptedFrom: (await this.adoptTeam({
384
+ workspacePath: canonical,
385
+ workspaceId: request.workspaceId,
386
+ derivedTeamId: request.teamId,
387
+ candidateTeamId: candidate.teamId,
388
+ replaceEmptyDerived: true,
389
+ ...request.actor === void 0 ? {} : { actor: request.actor }
390
+ })).adoptedFrom,
391
+ memberCount: candidate.memberCount ?? 0
392
+ };
393
+ } catch {
394
+ return;
395
+ }
396
+ }
397
+ async setDisabled(teamId, disabled) {
398
+ const location = this.requireLocation(teamId);
399
+ await withProcessLock(location.file, async () => {
400
+ if (disabled) await promises.writeFile(path.join(location.directory, DISABLED_MARKER), "", {
401
+ flag: "wx",
402
+ mode: 384
403
+ }).catch((error) => {
404
+ if (error.code !== "EEXIST") throw error;
405
+ });
406
+ else await promises.unlink(path.join(location.directory, DISABLED_MARKER)).catch((error) => {
407
+ if (error.code !== "ENOENT") throw error;
408
+ });
409
+ });
410
+ }
411
+ async renameTeam(teamId, expectedRevision, name) {
412
+ const result = await this.commit(teamId, {
413
+ expectedRevision,
414
+ mutate: (state) => {
415
+ const from = state.name ?? state.teamId;
416
+ state.name = name;
417
+ return from;
418
+ }
419
+ });
420
+ return {
421
+ state: result.state,
422
+ from: result.value
423
+ };
424
+ }
425
+ /**
426
+ * Flip the dispatch switch. Authority belongs to the callers, exactly as it does
427
+ * for `renameTeam`: the desktop route admits the human, and the lead-only tool
428
+ * admits the lead. A request that changes nothing is refused rather than silently
429
+ * accepted, so a double click cannot look like a state change that never happened.
430
+ */
431
+ async setDispatchMode(teamId, mode, request, expectedRevision) {
432
+ if (mode !== "auto" && mode !== "paused") throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `unknown dispatch mode: ${String(mode)}`);
433
+ const result = await this.commit(teamId, {
434
+ expectedRevision,
435
+ mutate: (state) => {
436
+ const from = readDispatchSwitch(state).mode;
437
+ if (from === mode) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `dispatch is already ${mode}`);
438
+ state.dispatch = {
439
+ mode,
440
+ by: request.actor,
441
+ ...request.reason === void 0 || request.reason.trim().length === 0 ? {} : { reason: request.reason.trim() },
442
+ at: Date.now()
443
+ };
444
+ return from;
445
+ }
446
+ });
447
+ return {
448
+ state: result.state,
449
+ from: result.value,
450
+ to: mode
451
+ };
452
+ }
453
+ async commit(teamId, request) {
454
+ const location = this.requireLocation(teamId);
455
+ if (!request || typeof request.mutate !== "function" || !Number.isInteger(request.expectedRevision)) throw new TypeError("expectedRevision and mutate are required");
456
+ return withProcessLock(location.file, async () => {
457
+ const current = await this.read(location);
458
+ if (current.revision !== request.expectedRevision) throw new LiveTeamsError(ERROR_CODES.REVISION_CONFLICT, `expected revision ${request.expectedRevision}, actual ${current.revision}`);
459
+ const working = clone(current);
460
+ const value = await request.mutate(working);
461
+ const impostor = working.members.find((member) => isReservedActorName(member.memberId));
462
+ if (impostor !== void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `"${impostor.memberId}" is a reserved actor name and cannot be a member id; ids the transport uses for the human, the scheduler and the team itself are refused`);
463
+ working.revision = current.revision + 1;
464
+ working.updatedAt = Date.now();
465
+ await atomicWriteJson(location.file, working);
466
+ return {
467
+ state: working,
468
+ value
469
+ };
470
+ });
471
+ }
472
+ async memberBySession(teamId, sessionId) {
473
+ const member = (await this.read(this.requireLocation(teamId))).members.find((entry) => entry.sessionId === sessionId);
474
+ return member === void 0 ? void 0 : clone(member);
475
+ }
476
+ async bindSession(teamId, memberId, sessionId, expectedRevision) {
477
+ const result = await this.commit(teamId, {
478
+ expectedRevision,
479
+ mutate: (state) => {
480
+ const member = state.members.find((entry) => entry.memberId === memberId);
481
+ if (!member) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `member not found: ${memberId}`);
482
+ const owner = state.members.find((entry) => entry.memberId !== memberId && entry.sessionId === sessionId);
483
+ if (owner) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `session ${sessionId} is already bound to ${owner.memberId}`);
484
+ member.sessionId = sessionId;
485
+ member.bindingGeneration += 1;
486
+ member.contractRevision = (member.contractRevision ?? 0) + 1;
487
+ return member;
488
+ }
489
+ });
490
+ return clone(result.value);
491
+ }
492
+ async unbindSession(teamId, memberId, expectedRevision) {
493
+ const result = await this.commit(teamId, {
494
+ expectedRevision,
495
+ mutate: (state) => {
496
+ const member = state.members.find((entry) => entry.memberId === memberId);
497
+ if (!member) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `member not found: ${memberId}`);
498
+ delete member.sessionId;
499
+ member.bindingGeneration += 1;
500
+ member.contractRevision = (member.contractRevision ?? 0) + 1;
501
+ return member;
502
+ }
503
+ });
504
+ return clone(result.value);
505
+ }
506
+ async setLead(teamId, memberId, expectedRevision) {
507
+ return (await this.commit(teamId, {
508
+ expectedRevision,
509
+ mutate: (state) => {
510
+ if (!state.members.some((entry) => entry.memberId === memberId)) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `member not found: ${memberId}`);
511
+ state.leadMemberId = memberId;
512
+ }
513
+ })).state;
514
+ }
515
+ async updateContactGrant(teamId, from, to, action, grantedBy) {
516
+ const location = this.requireLocation(teamId);
517
+ for (let attempt = 0; attempt < 3; attempt += 1) {
518
+ const current = await this.read(location);
519
+ const grants = current.contactGrants ?? [];
520
+ const exists = grants.some((grant) => grant.from === from && grant.to === to);
521
+ if (action === "add" && exists || action === "remove" && !exists) return {
522
+ state: clone(current),
523
+ grants: clone(grants.filter((grant) => grant.from === from)),
524
+ changed: false
525
+ };
526
+ try {
527
+ const result = await this.commit(teamId, {
528
+ expectedRevision: current.revision,
529
+ mutate: (state) => {
530
+ const next = state.contactGrants ?? [];
531
+ if (action === "add") next.push({
532
+ from,
533
+ to,
534
+ grantedBy,
535
+ at: Date.now()
536
+ });
537
+ else state.contactGrants = next.filter((grant) => !(grant.from === from && grant.to === to));
538
+ if (action === "add") state.contactGrants = next;
539
+ if (state.contactGrants?.length === 0) delete state.contactGrants;
540
+ return state.contactGrants?.filter((grant) => grant.from === from) ?? [];
541
+ }
542
+ });
543
+ return {
544
+ state: result.state,
545
+ grants: clone(result.value),
546
+ changed: true
547
+ };
548
+ } catch (error) {
549
+ if (error instanceof LiveTeamsError && error.code === ERROR_CODES.REVISION_CONFLICT && attempt < 2) continue;
550
+ throw error;
551
+ }
552
+ }
553
+ throw new LiveTeamsError(ERROR_CODES.REVISION_CONFLICT, "contact grant revision conflict");
554
+ }
555
+ async setMemberNote(teamId, memberId, note, expectedRevision) {
556
+ const result = await this.commit(teamId, {
557
+ expectedRevision,
558
+ mutate: (state) => {
559
+ const member = state.members.find((entry) => entry.memberId === memberId);
560
+ if (member === void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `member not found: ${memberId}`);
561
+ member.note = note;
562
+ return member;
563
+ }
564
+ });
565
+ return {
566
+ state: result.state,
567
+ member: clone(result.value)
568
+ };
569
+ }
570
+ async removeMember(teamId, memberId, expectedRevision) {
571
+ const result = await this.commit(teamId, {
572
+ expectedRevision,
573
+ mutate: (state) => {
574
+ if (state.archivedMemberIds.includes(memberId)) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `member already archived: ${memberId}`);
575
+ const index = state.members.findIndex((entry) => entry.memberId === memberId);
576
+ if (index < 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `member not found: ${memberId}`);
577
+ const removed = state.members[index];
578
+ if (removed === void 0) throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `member not found: ${memberId}`);
579
+ state.members.splice(index, 1);
580
+ state.archivedMemberIds.push(memberId);
581
+ if (state.leadMemberId === memberId) delete state.leadMemberId;
582
+ if (state.contactGrants !== void 0) {
583
+ state.contactGrants = state.contactGrants.filter((grant) => grant.from !== memberId && grant.to !== memberId);
584
+ if (state.contactGrants.length === 0) delete state.contactGrants;
585
+ }
586
+ return clone(removed);
587
+ }
588
+ });
589
+ return {
590
+ state: result.state,
591
+ removed: result.value
592
+ };
593
+ }
594
+ async appendEvent(teamId, event) {
595
+ const location = this.requireLocation(teamId);
596
+ return withProcessLock(location.eventsFile, async () => {
597
+ await promises.mkdir(location.directory, { recursive: true });
598
+ const handle = await promises.open(location.eventsFile, "a", 384);
599
+ try {
600
+ await handle.writeFile(`${JSON.stringify(event)}\n`, "utf8");
601
+ await handle.sync();
602
+ } finally {
603
+ await handle.close();
604
+ }
605
+ });
606
+ }
607
+ /**
608
+ * The audit, read from a byte offset when the caller has one.
609
+ *
610
+ * The journal grows forever and the panel re-reads it every few seconds; reading the whole file was
611
+ * the one part of polling that got worse with age (a ten-times-longer journal meant seventeen
612
+ * megabytes a minute). The file is append-only, so an offset is a safe cursor: the caller passes
613
+ * back the offset this returned and pays only for what is new. Without an offset the tail is read —
614
+ * never the whole history, which nobody reads in a panel anyway.
615
+ */
616
+ async readEvents(teamId, after) {
617
+ const location = this.requireLocation(teamId);
618
+ if (!await fileExists(location.eventsFile)) return {
619
+ events: [],
620
+ torn: null,
621
+ nextOffset: 0,
622
+ truncated: false
623
+ };
624
+ const size = (await promises.stat(location.eventsFile)).size;
625
+ const from = after !== void 0 && after >= 0 && after <= size ? after : Math.max(0, size - 262144);
626
+ const truncated = after === void 0 && from > 0;
627
+ const handle = await promises.open(location.eventsFile, "r");
628
+ let contents;
629
+ try {
630
+ const length = size - from;
631
+ const buffer = Buffer.alloc(length);
632
+ await handle.read(buffer, 0, length, from);
633
+ contents = buffer.toString("utf8");
634
+ if (from > 0) {
635
+ const boundary = Buffer.alloc(1);
636
+ await handle.read(boundary, 0, 1, from - 1);
637
+ if (boundary[0] !== 10) contents = contents.slice(contents.indexOf("\n") + 1);
638
+ }
639
+ } finally {
640
+ await handle.close();
641
+ }
642
+ if (contents.length === 0) return {
643
+ events: [],
644
+ torn: null,
645
+ nextOffset: size,
646
+ truncated
647
+ };
648
+ const hasTrailingNewline = contents.endsWith("\n");
649
+ const lines = contents.split("\n");
650
+ if (hasTrailingNewline) lines.pop();
651
+ const torn = hasTrailingNewline ? null : lines.pop() ?? null;
652
+ const events = [];
653
+ for (const line of lines) {
654
+ if (!line.trim()) continue;
655
+ try {
656
+ events.push(JSON.parse(line));
657
+ } catch (error) {
658
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid event record: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
659
+ }
660
+ }
661
+ const consumed = contents.endsWith("\n") ? contents.length : contents.length - (torn?.length ?? 0);
662
+ return {
663
+ events,
664
+ torn,
665
+ nextOffset: from + Buffer.byteLength(contents.slice(0, consumed), "utf8"),
666
+ truncated
667
+ };
668
+ }
669
+ location(workspacePath, workspaceId, teamId) {
670
+ const root = path.resolve(workspacePath, TEAM_ROOT, "teams", teamId);
671
+ return {
672
+ directory: root,
673
+ file: path.join(root, "team.json"),
674
+ eventsFile: path.join(root, "events.jsonl"),
675
+ workspacePath: path.resolve(workspacePath),
676
+ workspaceId,
677
+ teamId
678
+ };
679
+ }
680
+ requireLocation(teamId) {
681
+ const location = this.locations.get(teamId);
682
+ if (!location) throw new Error(`Team ${teamId} has not been opened`);
683
+ return location;
684
+ }
685
+ async read(location) {
686
+ let state;
687
+ try {
688
+ state = await readJson(location.file);
689
+ } catch (error) {
690
+ if ((error !== null && typeof error === "object" && "code" in error ? error.code : void 0) === "ENOENT") throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `missing state file: ${location.file}`, { cause: error });
691
+ if (error instanceof SyntaxError) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid JSON in ${location.file}: ${error.message}`, { cause: error });
692
+ if (error instanceof LiveTeamsError) throw error;
693
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read ${location.file}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
694
+ }
695
+ if (state === null || typeof state !== "object" || Array.isArray(state)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `state is not an object: ${location.file}`);
696
+ const candidate = state;
697
+ if (!Number.isInteger(candidate.schemaVersion)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "state schemaVersion is missing or invalid");
698
+ const schemaVersion = candidate.schemaVersion;
699
+ if (schemaVersion === void 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "state schemaVersion is missing or invalid");
700
+ if (schemaVersion > 1) throw new LiveTeamsError(ERROR_CODES.SCHEMA_UNSUPPORTED, `schemaVersion ${candidate.schemaVersion} is newer than 1`);
701
+ if (candidate.teamId !== location.teamId || candidate.workspaceId !== location.workspaceId || !Number.isInteger(candidate.revision) || !Array.isArray(candidate.members) || !Array.isArray(candidate.archivedMemberIds)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "state identity or required fields are invalid");
702
+ return candidate;
703
+ }
704
+ memberBySessionSync(teamId, sessionId) {
705
+ return readMemberBySessionSync({
706
+ workspacePath: this.requireLocation(teamId).workspacePath,
707
+ teamId,
708
+ sessionId
709
+ });
710
+ }
711
+ };
712
+ function createTeamStore(options = {}) {
713
+ return new TeamStore(options);
714
+ }
715
+ /**
716
+ * Read the dispatch switch strictly: an absent switch is `auto`, and anything
717
+ * malformed is corrupt state rather than a quiet fallback to automatic dispatch.
718
+ */
719
+ function readDispatchSwitch(state) {
720
+ const candidate = objectRecord(state.dispatch);
721
+ if (candidate === void 0) return {
722
+ mode: "auto",
723
+ by: "default",
724
+ at: 0
725
+ };
726
+ if (candidate.mode !== "auto" && candidate.mode !== "paused") throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "state dispatch mode is invalid");
727
+ if (typeof candidate.by !== "string" || candidate.by.length === 0) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "state dispatch actor is invalid");
728
+ if (!Number.isInteger(candidate.at)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "state dispatch timestamp is invalid");
729
+ if (candidate.reason !== void 0 && typeof candidate.reason !== "string") throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "state dispatch reason is invalid");
730
+ return candidate;
731
+ }
732
+ function readTeamStateSync(request) {
733
+ const file = path.join(path.resolve(request.workspacePath), TEAM_ROOT, "teams", request.teamId, "team.json");
734
+ let contents;
735
+ try {
736
+ contents = readFileSync(file, "utf8");
737
+ } catch (error) {
738
+ if (error.code === "ENOENT") return void 0;
739
+ throw error;
740
+ }
741
+ let state;
742
+ try {
743
+ state = JSON.parse(contents);
744
+ } catch (error) {
745
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid JSON in ${file}`, { cause: error });
746
+ }
747
+ if (state === null || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.members)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `team state has no member list: ${file}`);
748
+ return clone(state);
749
+ }
750
+ function readMemberBySessionSync(request) {
751
+ const file = path.join(path.resolve(request.workspacePath), TEAM_ROOT, "teams", request.teamId, "team.json");
752
+ let contents;
753
+ try {
754
+ contents = readFileSync(file, "utf8");
755
+ } catch (error) {
756
+ if (error.code === "ENOENT") return void 0;
757
+ throw error;
758
+ }
759
+ const state = JSON.parse(contents);
760
+ if (state === null || typeof state !== "object" || !Array.isArray(state.members)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `team state has no member list: ${file}`);
761
+ const member = state.members.find((entry) => entry !== null && typeof entry === "object" && entry.sessionId === request.sessionId);
762
+ return member === void 0 ? void 0 : clone(member);
763
+ }
764
+ //#endregion
765
+ export { RESERVED_ACTOR_NAMES, TEAM_ROOT, TeamStore, createTeamStore, isReservedActorName, readDispatchSwitch, readMemberBySessionSync, readTeamStateSync, resolveLeadMemberId };