xmux-bridge 1.0.39

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.
@@ -0,0 +1,887 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const { MailboxError } = require('../runtime/errors');
8
+ const { withFileLock } = require('../runtime/lock');
9
+ const {
10
+ atomicWriteJson,
11
+ isPlainObject,
12
+ readJson,
13
+ toJsonString,
14
+ } = require('../runtime/json');
15
+ const { nowTs, sleepMs } = require('../runtime/time');
16
+
17
+ const SCHEMA_TEAM = 'xmux.team.v1';
18
+ const SCHEMA_REQUEST = 'xmux.request.v1';
19
+
20
+ function randomHex() {
21
+ return crypto.randomBytes(16).toString('hex');
22
+ }
23
+
24
+ function expandUser(value) {
25
+ const text = String(value);
26
+ if (!text.startsWith('~')) {
27
+ return text;
28
+ }
29
+ const home = process.env.HOME;
30
+ if (!home) {
31
+ return text;
32
+ }
33
+ if (text === '~') {
34
+ return home;
35
+ }
36
+ if (text.startsWith('~/')) {
37
+ return path.join(home, text.slice(2));
38
+ }
39
+ return text;
40
+ }
41
+
42
+ function projectRoot(start) {
43
+ let current = path.resolve(expandUser(start));
44
+ while (true) {
45
+ if (fs.existsSync(path.join(current, '.git'))) {
46
+ return current;
47
+ }
48
+ const parent = path.dirname(current);
49
+ if (parent === current) {
50
+ return current;
51
+ }
52
+ current = parent;
53
+ }
54
+ }
55
+
56
+ function safeComponent(value, field) {
57
+ if (value === undefined || value === null) {
58
+ throw new MailboxError(`${field} is required`);
59
+ }
60
+ const text = String(value).trim();
61
+ if (!text || text === '.' || text === '..') {
62
+ throw new MailboxError(`${field} must be a non-empty path component`);
63
+ }
64
+ if (text.includes('/') || text.includes('\\')) {
65
+ throw new MailboxError(`${field} must not contain path separators`);
66
+ }
67
+ return text;
68
+ }
69
+
70
+ function storeRoot(root = null) {
71
+ if (root !== null && root !== undefined) {
72
+ return path.resolve(expandUser(root));
73
+ }
74
+ const envRoot = process.env.XMUX_STATE_DIR;
75
+ if (envRoot) {
76
+ return path.resolve(expandUser(envRoot));
77
+ }
78
+ const projectDir = process.env.XMUX_PROJECT_DIR;
79
+ if (projectDir) {
80
+ return path.resolve(expandUser(projectDir), '.codex', 'xmux');
81
+ }
82
+ return path.join(projectRoot(process.cwd()), '.codex', 'xmux');
83
+ }
84
+
85
+ function teamDir(team, root = null) {
86
+ return path.join(storeRoot(root), 'teams', safeComponent(team, 'team'));
87
+ }
88
+
89
+ function teamJsonPath(team, root = null) {
90
+ return path.join(teamDir(team, root), 'team.json');
91
+ }
92
+
93
+ function inboxPath(team, owner, root = null) {
94
+ return path.join(teamDir(team, root), 'inboxes', `${safeComponent(owner, 'inbox owner')}.json`);
95
+ }
96
+
97
+ function requestPath(team, requestId, root = null) {
98
+ return path.join(teamDir(team, root), 'requests', `${safeComponent(requestId, 'request_id')}.json`);
99
+ }
100
+
101
+ function eventsPath(team, root = null) {
102
+ return path.join(teamDir(team, root), 'events.jsonl');
103
+ }
104
+
105
+ function ensureTeamDirs(teamPath) {
106
+ fs.mkdirSync(teamPath, { recursive: true });
107
+ fs.mkdirSync(path.join(teamPath, 'inboxes'), { recursive: true });
108
+ fs.mkdirSync(path.join(teamPath, 'requests'), { recursive: true });
109
+ }
110
+
111
+ function cloneDefault(value) {
112
+ if (value === undefined || value === null) {
113
+ return value;
114
+ }
115
+ return JSON.parse(JSON.stringify(value));
116
+ }
117
+
118
+ function writeJsonLocked(filePath, data) {
119
+ withFileLock(filePath, () => {
120
+ atomicWriteJson(filePath, data);
121
+ });
122
+ }
123
+
124
+ function updateJsonLocked(filePath, defaultValue, updater) {
125
+ return withFileLock(filePath, () => {
126
+ const data = readJson(filePath, cloneDefault(defaultValue));
127
+ const result = updater(data);
128
+ atomicWriteJson(filePath, data);
129
+ return result;
130
+ });
131
+ }
132
+
133
+ function ensureJsonArray(filePath) {
134
+ if (fs.existsSync(filePath)) {
135
+ return;
136
+ }
137
+ updateJsonLocked(filePath, [], (data) => {
138
+ if (!Array.isArray(data)) {
139
+ throw new MailboxError(`${filePath} is not a JSON array`);
140
+ }
141
+ return null;
142
+ });
143
+ }
144
+
145
+ function ensureTextFile(filePath) {
146
+ if (fs.existsSync(filePath)) {
147
+ return;
148
+ }
149
+ withFileLock(filePath, () => {
150
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
151
+ if (!fs.existsSync(filePath)) {
152
+ fs.closeSync(fs.openSync(filePath, 'a'));
153
+ }
154
+ });
155
+ }
156
+
157
+ function readTeam(team, root = null) {
158
+ const filePath = teamJsonPath(team, root);
159
+ const data = readJson(filePath, null);
160
+ if (data === null) {
161
+ throw new MailboxError(`team not initialized: ${team}`);
162
+ }
163
+ if (!isPlainObject(data)) {
164
+ throw new MailboxError(`${filePath} is not a JSON object`);
165
+ }
166
+ return data;
167
+ }
168
+
169
+ function leadName(team, root = null) {
170
+ const data = readTeam(team, root);
171
+ const lead = data.lead || {};
172
+ const name = lead.name;
173
+ if (!name) {
174
+ throw new MailboxError(`team has no lead name: ${team}`);
175
+ }
176
+ return name;
177
+ }
178
+
179
+ function teammateNames(data) {
180
+ const members = isPlainObject(data.members) ? data.members : {};
181
+ const names = [];
182
+ for (const [name, member] of Object.entries(members).sort(([a], [b]) => a.localeCompare(b))) {
183
+ if (!isPlainObject(member)) {
184
+ continue;
185
+ }
186
+ if (member.role === 'lead') {
187
+ continue;
188
+ }
189
+ if (member.active === false) {
190
+ continue;
191
+ }
192
+ names.push(name);
193
+ }
194
+ return names;
195
+ }
196
+
197
+ function resolveTeammateTarget(data, requested) {
198
+ const target = safeComponent(requested, 'to');
199
+ const members = isPlainObject(data.members) ? data.members : {};
200
+
201
+ const direct = members[target];
202
+ if (isPlainObject(direct)) {
203
+ if (direct.role === 'lead') {
204
+ throw new MailboxError(`target is the lead, not a teammate: ${target}`);
205
+ }
206
+ if (direct.active === false) {
207
+ throw new MailboxError(`teammate is inactive: ${target}`);
208
+ }
209
+ return target;
210
+ }
211
+
212
+ const providerMatches = [];
213
+ for (const [name, candidate] of Object.entries(members).sort(([a], [b]) => a.localeCompare(b))) {
214
+ if (!isPlainObject(candidate)) {
215
+ continue;
216
+ }
217
+ if (candidate.role === 'lead') {
218
+ continue;
219
+ }
220
+ if (candidate.active === false) {
221
+ continue;
222
+ }
223
+ if (candidate.provider === target) {
224
+ providerMatches.push(name);
225
+ }
226
+ }
227
+
228
+ if (providerMatches.length === 1) {
229
+ return providerMatches[0];
230
+ }
231
+ if (providerMatches.length > 1) {
232
+ throw new MailboxError(
233
+ `ambiguous teammate provider '${target}': ${providerMatches.join(', ')}`,
234
+ );
235
+ }
236
+
237
+ const known = teammateNames(data);
238
+ const detail = known.length > 0 ? `; registered active teammates: ${known.join(', ')}` : '';
239
+ throw new MailboxError(`teammate not registered or inactive: ${target}${detail}`);
240
+ }
241
+
242
+ function resolveResponseSender(data, requested) {
243
+ const sender = safeComponent(requested, 'from');
244
+ const members = isPlainObject(data.members) ? data.members : {};
245
+ const member = members[sender];
246
+ if (!isPlainObject(member)) {
247
+ throw new MailboxError(`response sender not registered or inactive: ${sender}`);
248
+ }
249
+ if (member.role === 'lead') {
250
+ throw new MailboxError(`response sender is the lead, not a teammate: ${sender}`);
251
+ }
252
+ if (member.active === false) {
253
+ throw new MailboxError(`response sender not registered or inactive: ${sender}`);
254
+ }
255
+ return sender;
256
+ }
257
+
258
+ function appendEvent(team, event, options = {}) {
259
+ const {
260
+ root = null,
261
+ actor = null,
262
+ target = null,
263
+ requestId = null,
264
+ data = null,
265
+ } = options;
266
+ const filePath = eventsPath(team, root);
267
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
268
+ const record = {
269
+ ts: nowTs(),
270
+ event,
271
+ actor,
272
+ target,
273
+ request_id: requestId,
274
+ data: isPlainObject(data) ? data : {},
275
+ };
276
+ const line = `${toJsonString(record, false)}\n`;
277
+ withFileLock(filePath, () => {
278
+ fs.appendFileSync(filePath, line, 'utf8');
279
+ });
280
+ }
281
+
282
+ function appendInbox(team, owner, entry, root = null) {
283
+ const filePath = inboxPath(team, owner, root);
284
+ updateJsonLocked(filePath, [], (data) => {
285
+ if (!Array.isArray(data)) {
286
+ throw new MailboxError(`${filePath} is not a JSON array`);
287
+ }
288
+ data.push(entry);
289
+ return null;
290
+ });
291
+ }
292
+
293
+ function markLeadResponsesRead(team, requestId, root = null) {
294
+ const lead = leadName(team, root);
295
+ const filePath = inboxPath(team, lead, root);
296
+ return updateJsonLocked(filePath, [], (data) => {
297
+ if (!Array.isArray(data)) {
298
+ throw new MailboxError(`${filePath} is not a JSON array`);
299
+ }
300
+ let marked = 0;
301
+ for (const entry of data) {
302
+ if (
303
+ isPlainObject(entry)
304
+ && entry.type === 'response'
305
+ && entry.request_id === requestId
306
+ && !entry.read
307
+ ) {
308
+ entry.read = true;
309
+ entry.read_at = nowTs();
310
+ marked += 1;
311
+ }
312
+ }
313
+ return marked;
314
+ });
315
+ }
316
+
317
+ function markInboxRead(team, owner, timestamp = null, requestId = null, root = null) {
318
+ const ownerName = safeComponent(owner, 'owner');
319
+ const ts = String(timestamp || '');
320
+ const reqId = String(requestId || '');
321
+ const filePath = inboxPath(team, ownerName, root);
322
+
323
+ function entryRequestId(entry) {
324
+ const direct = entry.request_id || entry.requestId || '';
325
+ if (direct) {
326
+ return direct;
327
+ }
328
+ const rawText = entry.text ?? entry.message ?? '';
329
+ if (typeof rawText !== 'string') {
330
+ return '';
331
+ }
332
+ try {
333
+ const nested = JSON.parse(rawText);
334
+ if (isPlainObject(nested)) {
335
+ return nested.request_id || nested.requestId || '';
336
+ }
337
+ } catch (_) {
338
+ return '';
339
+ }
340
+ return '';
341
+ }
342
+
343
+ const marked = updateJsonLocked(filePath, [], (data) => {
344
+ if (!Array.isArray(data)) {
345
+ throw new MailboxError(`${filePath} is not a JSON array`);
346
+ }
347
+ let count = 0;
348
+ for (const entry of data) {
349
+ if (!isPlainObject(entry) || entry.read) {
350
+ continue;
351
+ }
352
+ if (ts && entry.timestamp === ts) {
353
+ entry.read = true;
354
+ } else if (reqId && entryRequestId(entry) === reqId) {
355
+ entry.read = true;
356
+ } else if (!ts && !reqId) {
357
+ entry.read = true;
358
+ } else {
359
+ continue;
360
+ }
361
+ entry.read_at = nowTs();
362
+ count += 1;
363
+ if (ts || reqId) {
364
+ break;
365
+ }
366
+ }
367
+ return count;
368
+ });
369
+
370
+ return {
371
+ status: 'ok',
372
+ team: safeComponent(team, 'team'),
373
+ owner: ownerName,
374
+ marked,
375
+ };
376
+ }
377
+
378
+ function initTeam(team, leadNameValue, leadProvider, leadPane = null, root = null) {
379
+ const dir = teamDir(team, root);
380
+ ensureTeamDirs(dir);
381
+ const teamName = safeComponent(team, 'team');
382
+ const lead = safeComponent(leadNameValue, 'lead_name');
383
+ const provider = safeComponent(leadProvider, 'lead_provider');
384
+ const filePath = path.join(dir, 'team.json');
385
+ const created = nowTs();
386
+
387
+ withFileLock(filePath, () => {
388
+ let data = readJson(filePath, null);
389
+ if (data === null) {
390
+ data = {};
391
+ }
392
+ if (!isPlainObject(data)) {
393
+ throw new MailboxError(`${filePath} is not a JSON object`);
394
+ }
395
+ if (!Object.prototype.hasOwnProperty.call(data, 'schema')) {
396
+ data.schema = SCHEMA_TEAM;
397
+ }
398
+ if (!Object.prototype.hasOwnProperty.call(data, 'name')) {
399
+ data.name = teamName;
400
+ }
401
+ if (!Object.prototype.hasOwnProperty.call(data, 'created_at')) {
402
+ data.created_at = created;
403
+ }
404
+ data.status = 'active';
405
+ data.updated_at = created;
406
+
407
+ const priorLead = isPlainObject(data.lead) ? data.lead : {};
408
+ data.lead = {
409
+ name: lead,
410
+ provider,
411
+ pane: leadPane,
412
+ registered_at: priorLead.registered_at || created,
413
+ updated_at: created,
414
+ };
415
+
416
+ let members = data.members;
417
+ if (!isPlainObject(members)) {
418
+ members = {};
419
+ data.members = members;
420
+ }
421
+ const existing = isPlainObject(members[lead]) ? members[lead] : {};
422
+ members[lead] = {
423
+ name: lead,
424
+ role: 'lead',
425
+ provider,
426
+ backend: existing.backend || provider,
427
+ pane: leadPane,
428
+ registered_at: existing.registered_at || created,
429
+ updated_at: created,
430
+ active: true,
431
+ };
432
+
433
+ atomicWriteJson(filePath, data);
434
+ });
435
+
436
+ ensureJsonArray(inboxPath(team, lead, root));
437
+ ensureTextFile(path.join(dir, 'events.jsonl'));
438
+ appendEvent(team, 'team.initialized', {
439
+ root,
440
+ actor: lead,
441
+ data: { lead_provider: provider, lead_pane: leadPane },
442
+ });
443
+
444
+ return {
445
+ status: 'ok',
446
+ team: teamName,
447
+ team_dir: dir,
448
+ lead_name: lead,
449
+ lead_provider: provider,
450
+ };
451
+ }
452
+
453
+ function registerMember(team, name, provider, pane = null, backend = 'tmux', root = null) {
454
+ const dir = teamDir(team, root);
455
+ if (!fs.existsSync(path.join(dir, 'team.json'))) {
456
+ throw new MailboxError(`team not initialized: ${team}`);
457
+ }
458
+ const member = safeComponent(name, 'name');
459
+ const providerName = safeComponent(provider, 'provider');
460
+ if (!['claude', 'gemini', 'copilot'].includes(providerName)) {
461
+ throw new MailboxError('teammate provider must be one of: claude, gemini, copilot');
462
+ }
463
+ const backendName = safeComponent(backend, 'backend');
464
+ const filePath = path.join(dir, 'team.json');
465
+ const updated = nowTs();
466
+
467
+ const result = updateJsonLocked(filePath, null, (data) => {
468
+ if (!isPlainObject(data)) {
469
+ throw new MailboxError(`${filePath} is not a JSON object`);
470
+ }
471
+ let members = data.members;
472
+ if (!isPlainObject(members)) {
473
+ members = {};
474
+ data.members = members;
475
+ }
476
+ const existing = isPlainObject(members[member]) ? members[member] : {};
477
+ members[member] = {
478
+ name: member,
479
+ role: existing.role || 'member',
480
+ provider: providerName,
481
+ backend: backendName,
482
+ pane,
483
+ registered_at: existing.registered_at || updated,
484
+ updated_at: updated,
485
+ active: true,
486
+ };
487
+ data.updated_at = updated;
488
+ return members[member];
489
+ });
490
+
491
+ ensureJsonArray(inboxPath(team, member, root));
492
+ appendEvent(team, 'member.registered', {
493
+ root,
494
+ actor: member,
495
+ data: { provider: providerName, backend: backendName, pane },
496
+ });
497
+ return { status: 'ok', team, member: result };
498
+ }
499
+
500
+ function updateMember(
501
+ team,
502
+ name,
503
+ {
504
+ provider = null,
505
+ pane = null,
506
+ backend = null,
507
+ session = null,
508
+ displayMode = null,
509
+ active = null,
510
+ root = null,
511
+ } = {},
512
+ ) {
513
+ const dir = teamDir(team, root);
514
+ if (!fs.existsSync(path.join(dir, 'team.json'))) {
515
+ throw new MailboxError(`team not initialized: ${team}`);
516
+ }
517
+ const member = safeComponent(name, 'name');
518
+ const filePath = path.join(dir, 'team.json');
519
+ const updated = nowTs();
520
+
521
+ const [result, changes] = updateJsonLocked(filePath, null, (data) => {
522
+ if (!isPlainObject(data)) {
523
+ throw new MailboxError(`${filePath} is not a JSON object`);
524
+ }
525
+ if (!isPlainObject(data.members)) {
526
+ data.members = {};
527
+ }
528
+ const existing = data.members[member];
529
+ if (!isPlainObject(existing)) {
530
+ throw new MailboxError(`member not registered: ${member}`);
531
+ }
532
+ const nextChanges = {};
533
+ const pairs = [
534
+ ['provider', provider],
535
+ ['backend', backend],
536
+ ['pane', pane],
537
+ ['session', session],
538
+ ['display_mode', displayMode],
539
+ ];
540
+ for (const [key, value] of pairs) {
541
+ if (value !== null && value !== undefined) {
542
+ nextChanges[key] = value;
543
+ }
544
+ }
545
+ if (active !== null && active !== undefined) {
546
+ nextChanges.active = Boolean(active);
547
+ }
548
+ Object.assign(existing, nextChanges);
549
+ existing.updated_at = updated;
550
+ data.updated_at = updated;
551
+ return [existing, nextChanges];
552
+ });
553
+
554
+ appendEvent(team, 'member.updated', {
555
+ root,
556
+ actor: member,
557
+ data: changes,
558
+ });
559
+ return { status: 'ok', team, member: result };
560
+ }
561
+
562
+ function enqueueRequest(team, to, fromName, message, requestId = null, root = null) {
563
+ const data = readTeam(team, root);
564
+ const target = resolveTeammateTarget(data, to);
565
+ const sender = safeComponent(fromName, 'from');
566
+ const reqId = requestId ? safeComponent(requestId, 'request_id') : randomHex();
567
+ const ts = nowTs();
568
+ const entryId = `msg-${randomHex()}`;
569
+ const entry = {
570
+ id: entryId,
571
+ type: 'request',
572
+ request_id: reqId,
573
+ from: sender,
574
+ to: target,
575
+ text: message,
576
+ timestamp: ts,
577
+ read: false,
578
+ status: 'pending',
579
+ };
580
+ const req = {
581
+ schema: SCHEMA_REQUEST,
582
+ request_id: reqId,
583
+ team: safeComponent(team, 'team'),
584
+ from: sender,
585
+ to: target,
586
+ message,
587
+ status: 'pending',
588
+ created_at: ts,
589
+ updated_at: ts,
590
+ inbox_entry_id: entryId,
591
+ responses: [],
592
+ };
593
+ const reqFile = requestPath(team, reqId, root);
594
+ withFileLock(reqFile, () => {
595
+ if (fs.existsSync(reqFile)) {
596
+ throw new MailboxError(`request already exists: ${reqId}`);
597
+ }
598
+ atomicWriteJson(reqFile, req);
599
+ });
600
+ appendInbox(team, target, entry, root);
601
+ appendEvent(team, 'request.enqueued', {
602
+ root,
603
+ actor: sender,
604
+ target,
605
+ requestId: reqId,
606
+ data: { inbox_entry_id: entryId },
607
+ });
608
+ return { status: 'pending', request_id: reqId, to: target };
609
+ }
610
+
611
+ function writeResponse(
612
+ team,
613
+ fromName,
614
+ text,
615
+ {
616
+ summary = null,
617
+ requestId = null,
618
+ status = 'done',
619
+ root = null,
620
+ } = {},
621
+ ) {
622
+ if (!['done', 'pending'].includes(status)) {
623
+ throw new MailboxError('status must be done or pending');
624
+ }
625
+ const teamData = readTeam(team, root);
626
+ const lead = (teamData.lead || {}).name;
627
+ if (!lead) {
628
+ throw new MailboxError(`team has no lead name: ${team}`);
629
+ }
630
+ const sender = resolveResponseSender(teamData, fromName);
631
+ const reqId = requestId ? safeComponent(requestId, 'request_id') : null;
632
+ const ts = nowTs();
633
+ const responseId = `rsp-${randomHex()}`;
634
+ const response = {
635
+ id: responseId,
636
+ type: 'response',
637
+ request_id: reqId,
638
+ from: sender,
639
+ to: lead,
640
+ text,
641
+ summary,
642
+ timestamp: ts,
643
+ read: false,
644
+ status,
645
+ };
646
+
647
+ appendInbox(team, lead, response, root);
648
+
649
+ if (reqId) {
650
+ const filePath = requestPath(team, reqId, root);
651
+ withFileLock(filePath, () => {
652
+ let data = readJson(filePath, null);
653
+ if (data === null) {
654
+ data = {
655
+ schema: SCHEMA_REQUEST,
656
+ request_id: reqId,
657
+ team: safeComponent(team, 'team'),
658
+ from: null,
659
+ to: sender,
660
+ message: null,
661
+ created_at: ts,
662
+ responses: [],
663
+ };
664
+ }
665
+ if (!isPlainObject(data)) {
666
+ throw new MailboxError(`${filePath} is not a JSON object`);
667
+ }
668
+ if (!Array.isArray(data.responses)) {
669
+ data.responses = [];
670
+ }
671
+ data.responses.push(response);
672
+ data.status = status;
673
+ data.updated_at = ts;
674
+ atomicWriteJson(filePath, data);
675
+ });
676
+ }
677
+
678
+ appendEvent(team, 'response.written', {
679
+ root,
680
+ actor: sender,
681
+ target: lead,
682
+ requestId: reqId,
683
+ data: { response_id: responseId, status },
684
+ });
685
+ return {
686
+ status,
687
+ request_id: reqId,
688
+ response_id: responseId,
689
+ to: lead,
690
+ };
691
+ }
692
+
693
+ function readResponse(team, requestId, markRead = false, root = null) {
694
+ const reqId = safeComponent(requestId, 'request_id');
695
+ const filePath = requestPath(team, reqId, root);
696
+ const data = readJson(filePath, null);
697
+ if (data === null) {
698
+ return { status: 'missing', request_id: reqId };
699
+ }
700
+ if (!isPlainObject(data)) {
701
+ throw new MailboxError(`${filePath} is not a JSON object`);
702
+ }
703
+ const responses = Array.isArray(data.responses) ? data.responses : [];
704
+ const done = data.status === 'done' && responses.length > 0;
705
+ if (!done) {
706
+ return { status: 'pending', request_id: reqId };
707
+ }
708
+ const marked = markRead ? markLeadResponsesRead(team, reqId, root) : 0;
709
+ const result = {
710
+ status: 'done',
711
+ request_id: reqId,
712
+ response: responses[responses.length - 1],
713
+ };
714
+ if (markRead) {
715
+ result.marked_read = marked;
716
+ }
717
+ return result;
718
+ }
719
+
720
+ function waitResponse(
721
+ team,
722
+ requestId,
723
+ {
724
+ timeout = 60.0,
725
+ interval = 1.0,
726
+ markRead = false,
727
+ root = null,
728
+ } = {},
729
+ ) {
730
+ const timeoutSeconds = Math.max(Number(timeout) || 0.0, 0.0);
731
+ const intervalSeconds = Math.max(Number(interval) || 0.0, 0.001);
732
+ const deadline = Date.now() + (timeoutSeconds * 1000);
733
+
734
+ while (true) {
735
+ const result = readResponse(team, requestId, markRead, root);
736
+ if (result.status === 'done' || result.status === 'missing') {
737
+ result.timed_out = false;
738
+ return result;
739
+ }
740
+ const remainingMs = deadline - Date.now();
741
+ if (remainingMs <= 0) {
742
+ result.timed_out = true;
743
+ return result;
744
+ }
745
+ sleepMs(Math.min(intervalSeconds * 1000, remainingMs));
746
+ }
747
+ }
748
+
749
+ function teamStatus(team, root = null) {
750
+ const dir = teamDir(team, root);
751
+ const filePath = path.join(dir, 'team.json');
752
+ const data = readJson(filePath, null);
753
+ if (data === null) {
754
+ return { status: 'missing', team: safeComponent(team, 'team') };
755
+ }
756
+ if (!isPlainObject(data)) {
757
+ throw new MailboxError(`${filePath} is not a JSON object`);
758
+ }
759
+
760
+ const inboxes = {};
761
+ const inboxDir = path.join(dir, 'inboxes');
762
+ if (fs.existsSync(inboxDir)) {
763
+ for (const name of fs.readdirSync(inboxDir).sort()) {
764
+ if (!name.endsWith('.json')) {
765
+ continue;
766
+ }
767
+ const messages = readJson(path.join(inboxDir, name), []);
768
+ if (!Array.isArray(messages)) {
769
+ continue;
770
+ }
771
+ inboxes[path.basename(name, '.json')] = {
772
+ total: messages.length,
773
+ unread: messages.filter((msg) => !msg.read).length,
774
+ };
775
+ }
776
+ }
777
+
778
+ const requestCounts = { total: 0, pending: 0, done: 0 };
779
+ const requestsDir = path.join(dir, 'requests');
780
+ if (fs.existsSync(requestsDir)) {
781
+ for (const name of fs.readdirSync(requestsDir).sort()) {
782
+ if (!name.endsWith('.json')) {
783
+ continue;
784
+ }
785
+ const req = readJson(path.join(requestsDir, name), {});
786
+ if (!isPlainObject(req)) {
787
+ continue;
788
+ }
789
+ requestCounts.total += 1;
790
+ const status = req.status || 'pending';
791
+ if (status === 'done') {
792
+ requestCounts.done += 1;
793
+ } else {
794
+ requestCounts.pending += 1;
795
+ }
796
+ }
797
+ }
798
+
799
+ return {
800
+ status: 'ok',
801
+ team: data.name || safeComponent(team, 'team'),
802
+ team_status: data.status || 'active',
803
+ team_dir: dir,
804
+ lead: data.lead,
805
+ members: data.members || {},
806
+ inboxes,
807
+ requests: requestCounts,
808
+ };
809
+ }
810
+
811
+ function listEvents(team, status = null, root = null) {
812
+ const filePath = eventsPath(team, root);
813
+ const events = [];
814
+ try {
815
+ const raw = fs.readFileSync(filePath, 'utf8');
816
+ for (const line of raw.split('\n')) {
817
+ const trimmed = line.trim();
818
+ if (!trimmed) {
819
+ continue;
820
+ }
821
+ let record;
822
+ try {
823
+ record = JSON.parse(trimmed);
824
+ } catch (_) {
825
+ continue;
826
+ }
827
+ if (status && ((record.data || {}).status !== status)) {
828
+ continue;
829
+ }
830
+ events.push(record);
831
+ }
832
+ } catch (err) {
833
+ if (!err || err.code !== 'ENOENT') {
834
+ throw err;
835
+ }
836
+ }
837
+ return {
838
+ status: 'ok',
839
+ team: safeComponent(team, 'team'),
840
+ events,
841
+ };
842
+ }
843
+
844
+ function listRequests(team, status = null, root = null) {
845
+ const reqDir = path.join(teamDir(team, root), 'requests');
846
+ const requests = [];
847
+ if (fs.existsSync(reqDir)) {
848
+ for (const name of fs.readdirSync(reqDir).sort()) {
849
+ if (!name.endsWith('.json')) {
850
+ continue;
851
+ }
852
+ const data = readJson(path.join(reqDir, name), null);
853
+ if (!isPlainObject(data)) {
854
+ continue;
855
+ }
856
+ if (status && data.status !== status) {
857
+ continue;
858
+ }
859
+ requests.push(data);
860
+ }
861
+ }
862
+ return {
863
+ status: 'ok',
864
+ team: safeComponent(team, 'team'),
865
+ requests,
866
+ };
867
+ }
868
+
869
+ module.exports = {
870
+ MailboxError,
871
+ enqueueRequest,
872
+ initTeam,
873
+ listEvents,
874
+ listRequests,
875
+ markInboxRead,
876
+ readResponse,
877
+ registerMember,
878
+ teamDir,
879
+ teamStatus,
880
+ updateMember,
881
+ waitResponse,
882
+ writeResponse,
883
+ writeJsonLocked,
884
+ SCHEMA_REQUEST,
885
+ SCHEMA_TEAM,
886
+ storeRoot,
887
+ };