@wowyuarm/dsh-agent-team 0.1.11 → 0.1.13

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 (114) hide show
  1. package/README.md +6 -4
  2. package/README.zh.md +6 -4
  3. package/cordis.patch.yml +7 -3
  4. package/package.json +16 -3
  5. package/packages/agent-team/README.md +4 -4
  6. package/packages/agent-team/README.zh.md +4 -4
  7. package/packages/agent-team/lib/context-projection.js +5 -1
  8. package/packages/agent-team/lib/index.js +162 -113
  9. package/packages/agent-team/lib/ledger.js +496 -169
  10. package/packages/agent-team/lib/member-context.js +18 -1
  11. package/packages/agent-team/lib/member-runtime.js +48 -9
  12. package/packages/agent-team/lib/mentions.js +80 -0
  13. package/packages/agent-team/lib/spec.js +20 -0
  14. package/packages/agent-team/lib/typert.host.js +241 -46
  15. package/packages/agent-team/lib/typert.remote-client.d.ts +7 -3
  16. package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
  17. package/packages/agent-team/lib/typert.remote-client.js +178 -28
  18. package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
  19. package/packages/agent-team/lib/types/index.d.ts +33 -14
  20. package/packages/agent-team/lib/types/index.d.ts.map +1 -1
  21. package/packages/agent-team/lib/types/ledger.d.ts +120 -18
  22. package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
  23. package/packages/agent-team/lib/types/member-context.d.ts +6 -0
  24. package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
  25. package/packages/agent-team/lib/types/member-runtime.d.ts +2 -1
  26. package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
  27. package/packages/agent-team/lib/types/mentions.d.ts +47 -0
  28. package/packages/agent-team/lib/types/mentions.d.ts.map +1 -0
  29. package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
  30. package/packages/agent-team/lib/types/types/entities.d.ts +3 -20
  31. package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
  32. package/packages/agent-team/lib/types/types/operations.d.ts +37 -1
  33. package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
  34. package/packages/agent-team/lib/types/types/requests-results.d.ts +107 -25
  35. package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
  36. package/packages/agent-team/lib/types/vendor/storage-sqlite/index.d.ts +87 -0
  37. package/packages/agent-team/lib/types/vendor/storage-sqlite/index.d.ts.map +1 -0
  38. package/packages/agent-team/lib/types/vendor/storage-sqlite/schema.d.ts +59 -0
  39. package/packages/agent-team/lib/types/vendor/storage-sqlite/schema.d.ts.map +1 -0
  40. package/packages/agent-team/lib/types/vendor/storage-sqlite/unit.d.ts +57 -0
  41. package/packages/agent-team/lib/types/vendor/storage-sqlite/unit.d.ts.map +1 -0
  42. package/packages/agent-team/lib/vendor/storage-sqlite/index.js +139 -0
  43. package/packages/agent-team/lib/vendor/storage-sqlite/schema.js +120 -0
  44. package/packages/agent-team/lib/vendor/storage-sqlite/unit.js +132 -0
  45. package/packages/agent-team/preset/team-member/agent.cordis.yml +48 -8
  46. package/packages/client-agent-team/README.md +1 -1
  47. package/packages/client-agent-team/README.zh.md +1 -1
  48. package/packages/client-agent-team/lib/client.js +2123 -1406
  49. package/packages/client-agent-team/lib/client.js.map +1 -1
  50. package/packages/client-agent-team/lib/types/client/TeamAgentImport.d.ts +12 -0
  51. package/packages/client-agent-team/lib/types/client/TeamAgentImport.d.ts.map +1 -0
  52. package/packages/client-agent-team/lib/types/client/TeamAgentImport.js +69 -0
  53. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +3 -1
  54. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
  55. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +31 -19
  56. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts +18 -0
  57. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts.map +1 -0
  58. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.js +22 -0
  59. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts +3 -1
  60. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
  61. package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +111 -24
  62. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
  63. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +10 -3
  64. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
  65. package/packages/client-agent-team/lib/types/client/TeamComposer.js +11 -17
  66. package/packages/client-agent-team/lib/types/client/TeamConversation.js +1 -1
  67. package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts +20 -0
  68. package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts.map +1 -0
  69. package/packages/client-agent-team/lib/types/client/TeamCountBadge.js +25 -0
  70. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +9 -5
  71. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts.map +1 -1
  72. package/packages/client-agent-team/lib/types/client/TeamInboxPage.js +92 -23
  73. package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts +43 -0
  74. package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts.map +1 -0
  75. package/packages/client-agent-team/lib/types/client/TeamMemberRow.js +27 -0
  76. package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts +2 -2
  77. package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts.map +1 -1
  78. package/packages/client-agent-team/lib/types/client/TeamMembersAction.js +2 -2
  79. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts +1 -1
  80. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts.map +1 -1
  81. package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
  82. package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +21 -31
  83. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
  84. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
  85. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +29 -11
  86. package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
  87. package/packages/client-agent-team/lib/types/client/index.js +11 -5
  88. package/packages/client-agent-team/lib/types/client/locales.d.ts +54 -16
  89. package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
  90. package/packages/client-agent-team/lib/types/client/locales.js +54 -16
  91. package/packages/client-agent-team/lib/types/client/slots.d.ts +3 -2
  92. package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
  93. package/packages/client-agent-team/lib/types/client/team-changes.d.ts +15 -25
  94. package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -1
  95. package/packages/client-agent-team/lib/types/client/team-changes.js +82 -80
  96. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +45 -10
  97. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
  98. package/packages/client-agent-team/lib/types/client/team-formatters.js +71 -30
  99. package/packages/tool-agent-team/README.md +3 -3
  100. package/packages/tool-agent-team/README.zh.md +3 -3
  101. package/packages/tool-agent-team/lib/host-access.js +19 -0
  102. package/packages/tool-agent-team/lib/index.js +71 -49
  103. package/packages/tool-agent-team/lib/types/host-access.d.ts +10 -0
  104. package/packages/tool-agent-team/lib/types/host-access.d.ts.map +1 -1
  105. package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
  106. package/packages/agent-team/lib/progress-nudge.js +0 -328
  107. package/packages/agent-team/lib/types/progress-nudge.d.ts +0 -136
  108. package/packages/agent-team/lib/types/progress-nudge.d.ts.map +0 -1
  109. package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts +0 -3
  110. package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts.map +0 -1
  111. package/packages/client-agent-team/lib/types/client/TeamSettings.js +0 -5
  112. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts +0 -41
  113. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts.map +0 -1
  114. package/packages/client-agent-team/lib/types/client/scope-coverage.js +0 -64
@@ -1,46 +1,59 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isDeepStrictEqual } from 'node:util';
3
3
  import { formatTeamTimestamp } from "./time-format.js";
4
+ import { resolveBodyMentions } from "./mentions.js";
4
5
  /** Stable Human Member identity shared by every replay of one dshHome Team. */
5
6
  export const AGENT_TEAM_HUMAN_MEMBER_ID = 'member:human';
7
+ /**
8
+ * The handle the Human is addressed by in Message bodies. The body parser and
9
+ * the Member directory both read this one constant, so a configurable display
10
+ * name has a single place to land instead of a hard-coded literal per surface.
11
+ */
12
+ export const AGENT_TEAM_HUMAN_HANDLE = 'human';
6
13
  /** Idempotency identity of the one Host bootstrap operation. */
7
14
  export const AGENT_TEAM_INITIALIZE_REQUEST_ID = 'agent-team:initialize:v1';
8
15
  const HUMAN_ACTOR = Object.freeze({
9
16
  kind: 'human',
10
17
  memberId: AGENT_TEAM_HUMAN_MEMBER_ID,
11
- handle: 'human',
18
+ handle: AGENT_TEAM_HUMAN_HANDLE,
12
19
  });
13
20
  /** Resolve the one Human authority owned by this Team. */
14
21
  export function agentTeamHumanActor() {
15
22
  return HUMAN_ACTOR;
16
23
  }
17
24
  function emptyProjection() {
18
- return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
25
+ return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), participations: new Map(), memberships: new Map(),
19
26
  claims: new Map(), tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
20
27
  orderedFacts: [], factsByThread: new Map(), channelRefByThread: new Map(), mentionsByMessage: new Map(), messageCountByThread: new Map(),
21
28
  messagesByRef: new Map(),
22
29
  attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map(),
23
30
  anchorByThread: new Map(), taskNumberByTask: new Map(), taskCountByChannel: new Map(), attentionThreadsByMember: new Map(),
31
+ threadsByWriter: new Map(),
24
32
  directMarkersByMember: new Map(), activityMarkersByMember: new Map(), observationsByThread: new Map() };
25
33
  }
26
- const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
27
- progress: Object.freeze([]),
28
- claim: Object.freeze([]),
29
- });
30
34
  /** Exhaustiveness guard for the closed operation union: adding a kind must update every dispatch. */
31
35
  function assertUnhandledKind(operation) {
32
36
  throw new Error(`agent-team ledger does not handle operation kind '${operation.kind}'`);
33
37
  }
34
38
  /**
35
- * Opening-line preview for a direct-only Human Inbox row: the Thread anchor's
36
- * first line, trimmed, then capped at 120 characters with an explicit mark —
37
- * the same bound the Thread page applies to its Task title, so the row and
38
- * the page it opens never disagree about what a Thread is about.
39
+ * Opening-line preview for a Human Inbox row: the Thread anchor's first line,
40
+ * trimmed, then capped at 120 characters with an explicit mark — the same bound
41
+ * the Thread page applies to its Task title, so the row and the page it opens
42
+ * never disagree about what a Thread is about.
39
43
  */
40
44
  function boundedInboxPreview(body) {
41
45
  const firstLine = body.split('\n', 1)[0]?.trim() ?? '';
42
46
  return firstLine.length > 120 ? `${firstLine.slice(0, 119)}…` : firstLine;
43
47
  }
48
+ /**
49
+ * How many Threads one Workspace's 「最近活跃」 slice may carry. It bounds what
50
+ * the Host hands over, not what the reader sees: the Client merges every
51
+ * visible Workspace's slice and trims the merged list to its own visible bound
52
+ * (`RECENT_ROWS_LIMIT`), which must stay at or below this one so no Workspace
53
+ * is cut short before the merge. The unread slice keeps its own `limit`
54
+ * request field.
55
+ */
56
+ const RECENT_INBOX_LIMIT = 10;
44
57
  /**
45
58
  * Whether one operation's Inbox delta would change nothing. A read that finds
46
59
  * no unread fact for its reader and no marker to consume produces exactly this
@@ -252,7 +265,8 @@ export class AgentTeamLedger {
252
265
  if (handle === '')
253
266
  throw new Error('member handle must not be empty');
254
267
  if (handle !== prior.handle)
255
- this.assertHandleAvailable(prior.workspaceId, handle, prior.memberId);
268
+ for (const workspaceId of this.workspacesOf(prior.memberId))
269
+ this.assertHandleAvailable(workspaceId, handle, prior.memberId);
256
270
  this.assertModelSelection(request.model);
257
271
  this.assertCapabilities(request.capabilities);
258
272
  // An absent model or capabilities field must CLEAR any override
@@ -439,80 +453,6 @@ export class AgentTeamLedger {
439
453
  }
440
454
  return active.sort((a, b) => a.claimRef.localeCompare(b.claimRef));
441
455
  }
442
- /**
443
- * Current progress-nudge candidacy for one Member: threads whose Thread
444
- * progress reminder applies (active Claim, or following a taskless Thread)
445
- * and tasks where the Claim reminder applies (following a still-`todo` Task
446
- * with no Claim history for this Member). A deep read-only projection — the
447
- * caller never re-derives attention, claim, task-resolution, or channel
448
- * archive state. Archived channels and resolved tasks produce no candidacy;
449
- * results are ordered by Thread revision descending, then threadRef.
450
- */
451
- progressNudgeTargets(memberId) {
452
- const member = this.state.members.get(memberId);
453
- if (member === undefined || member.state !== 'enabled')
454
- return EMPTY_PROGRESS_NUDGE_TARGETS;
455
- const progress = [];
456
- const claim = [];
457
- const seenProgress = new Set();
458
- for (const claimRecord of this.state.claims.values()) {
459
- if (claimRecord.owner !== memberId || claimRecord.state !== 'active')
460
- continue;
461
- const task = this.state.tasks.get(claimRecord.taskRef);
462
- const thread = this.state.threads.get(claimRecord.threadRef);
463
- if (task === undefined || thread === undefined || task.resolution !== 'open')
464
- continue;
465
- if (this.state.channels.get(task.channelRef)?.state === 'archived')
466
- continue;
467
- if (seenProgress.has(claimRecord.threadRef))
468
- continue;
469
- seenProgress.add(claimRecord.threadRef);
470
- progress.push({ reason: 'active-claim', threadRef: claimRecord.threadRef, taskRef: claimRecord.taskRef });
471
- }
472
- for (const [threadRef, followers] of this.state.attentionByThread) {
473
- if (!followers.has(memberId))
474
- continue;
475
- const thread = this.state.threads.get(threadRef);
476
- if (thread === undefined)
477
- continue;
478
- const channelRef = this.state.channelRefByThread.get(threadRef);
479
- if (channelRef === undefined || this.state.channels.get(channelRef)?.state === 'archived')
480
- continue;
481
- if (thread.taskRef === undefined) {
482
- if (!seenProgress.has(threadRef)) {
483
- seenProgress.add(threadRef);
484
- progress.push({ reason: 'taskless-follower', threadRef });
485
- }
486
- continue;
487
- }
488
- // Claim reminder: only still-`todo` tasks with zero lifetime claims by
489
- // this Member; once claimed (any state) the Member moved into the
490
- // progress-reminder regime.
491
- if (this.hasLifetimeClaimOnTask(memberId, thread.taskRef))
492
- continue;
493
- const task = this.state.tasks.get(thread.taskRef);
494
- if (task === undefined || task.resolution !== 'open' || this.deriveTaskStatus(task.taskRef, this.state.claims.values()) !== 'todo')
495
- continue;
496
- claim.push({ threadRef, taskRef: thread.taskRef });
497
- }
498
- const byThreadRevision = (left, right) => {
499
- const leftRevision = this.state.threads.get(left.threadRef)?.revision ?? 0;
500
- const rightRevision = this.state.threads.get(right.threadRef)?.revision ?? 0;
501
- return rightRevision - leftRevision || (left.threadRef < right.threadRef ? -1 : left.threadRef > right.threadRef ? 1 : 0);
502
- };
503
- return Object.freeze({
504
- progress: Object.freeze(progress.sort(byThreadRevision)),
505
- claim: Object.freeze(claim.sort(byThreadRevision)),
506
- });
507
- }
508
- /** Whether this Member has ever held a Claim on the Task, in any state. */
509
- hasLifetimeClaimOnTask(memberId, taskRef) {
510
- for (const claim of this.state.claims.values()) {
511
- if (claim.owner === memberId && claim.taskRef === taskRef)
512
- return true;
513
- }
514
- return false;
515
- }
516
456
  listMembers() {
517
457
  return Object.freeze([...this.state.members.values()]);
518
458
  }
@@ -526,8 +466,8 @@ export class AgentTeamLedger {
526
466
  this.assertHumanActor(request.actor);
527
467
  const channel = this.requireActiveChannel(request.workspaceId, request.channelRef);
528
468
  const member = this.requireMember(request.memberId);
529
- if (member.workspaceId !== request.workspaceId)
530
- throw new Error('Member and Channel must belong to one Workspace');
469
+ if (!this.participatesIn(member.memberId, request.workspaceId))
470
+ throw new Error('Agent Member does not participate in the Channel\'s Workspace');
531
471
  if (member.state !== 'enabled')
532
472
  throw new Error(`Agent Member '${member.memberId}' is ${member.state}; only enabled Members can join a Channel`);
533
473
  if (this.isChannelMember(channel.channelRef, member.memberId))
@@ -551,7 +491,7 @@ export class AgentTeamLedger {
551
491
  this.assertHumanActor(request.actor);
552
492
  const channel = this.requireChannel(request.workspaceId, request.channelRef);
553
493
  const member = this.requireMember(request.memberId);
554
- if (member.workspaceId !== channel.workspaceId || !this.isChannelMember(channel.channelRef, member.memberId)) {
494
+ if (!this.participatesIn(member.memberId, channel.workspaceId) || !this.isChannelMember(channel.channelRef, member.memberId)) {
555
495
  throw new Error(`Agent Member '${member.memberId}' is not a member of Channel '${channel.channelRef}'`);
556
496
  }
557
497
  const threadRefs = this.channelThreadRefs(channel.channelRef);
@@ -576,6 +516,86 @@ export class AgentTeamLedger {
576
516
  return this.committed(this.channelMemberRemovalResult(operation));
577
517
  });
578
518
  }
519
+ /**
520
+ * Join one Agent Member to one additional Workspace. Participation is a
521
+ * pure relation — no Session is created or moved; the Member's Session
522
+ * stays rooted in its default Workspace and collaboration in the joined
523
+ * Workspace is ledger work addressed by the workspaceId.
524
+ */
525
+ joinWorkspace(request) {
526
+ return this.enqueue(async () => {
527
+ const existing = this.state.byRequest.get(request.requestId);
528
+ if (existing !== undefined) {
529
+ this.assertSameWorkspaceJoin(existing, request);
530
+ return this.resolved(this.workspaceJoinResult(existing));
531
+ }
532
+ this.assertHumanActor(request.actor);
533
+ const member = this.requireMember(request.memberId);
534
+ if (member.state === 'inactive')
535
+ throw new Error(`Agent Member '${member.memberId}' is inactive and cannot join a Workspace`);
536
+ if (member.state === 'archived')
537
+ throw new Error(`Agent Member '${member.memberId}' is archived and cannot join a Workspace`);
538
+ if (this.participatesIn(member.memberId, request.workspaceId))
539
+ throw new Error(`Agent Member '${member.memberId}' already participates in Workspace '${request.workspaceId}'`);
540
+ // Handles resolve per Workspace: a second live Member with the same
541
+ // handle in the target would make mentions ambiguous there.
542
+ this.assertHandleAvailable(request.workspaceId, member.handle);
543
+ const operation = Object.freeze({
544
+ ...this.operationBase(request, this.nextSequence()), kind: 'team/member-workspace-joined',
545
+ data: Object.freeze({ workspaceId: request.workspaceId, memberId: member.memberId }),
546
+ });
547
+ await this.table.put(operation.operationId, operation);
548
+ this.apply(operation);
549
+ return this.committed(this.workspaceJoinResult(operation));
550
+ });
551
+ }
552
+ /**
553
+ * Withdraw one Agent Member from one of its non-default Workspaces. The
554
+ * default Workspace cannot be left — ending it is the archive path. Every
555
+ * active Claim the Member holds on the Workspace's Threads releases with
556
+ * public Activities, its Attention and markers on those Threads clear, and
557
+ * its Channel memberships there end; identity, Session, and remaining
558
+ * participations are untouched.
559
+ */
560
+ leaveWorkspace(request) {
561
+ return this.enqueue(async () => {
562
+ const existing = this.state.byRequest.get(request.requestId);
563
+ if (existing !== undefined) {
564
+ this.assertSameWorkspaceLeave(existing, request);
565
+ return this.resolved(this.workspaceLeaveResult(existing));
566
+ }
567
+ this.assertHumanActor(request.actor);
568
+ const member = this.requireMember(request.memberId);
569
+ if (member.state === 'inactive')
570
+ throw new Error(`Agent Member '${member.memberId}' is already inactive`);
571
+ if (member.state === 'archived')
572
+ throw new Error(`Agent Member '${member.memberId}' is already archived`);
573
+ if (request.workspaceId === member.workspaceId)
574
+ throw new Error(`Agent Member '${member.memberId}' cannot leave its default Workspace '${request.workspaceId}'; archive the Member instead`);
575
+ if (!this.participatesIn(member.memberId, request.workspaceId))
576
+ throw new Error(`Agent Member '${member.memberId}' does not participate in Workspace '${request.workspaceId}'`);
577
+ const threadRefs = this.workspaceThreadRefs(request.workspaceId);
578
+ const releasedClaims = [...this.state.claims.values()]
579
+ .filter(claim => claim.owner === member.memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
580
+ .map(claim => Object.freeze({ ...claim, state: 'released' }));
581
+ const projectedClaims = new Map(this.state.claims);
582
+ for (const claim of releasedClaims)
583
+ projectedClaims.set(claim.claimRef, claim);
584
+ const sequence = this.nextSequence();
585
+ const activities = this.releaseSummaries(releasedClaims, member.memberId, sequence);
586
+ const threads = this.threadsForActivities(activities);
587
+ const tasks = this.tasksForClaims(releasedClaims, projectedClaims);
588
+ const inbox = this.removeMemberThreadInbox(member.memberId, threadRefs);
589
+ const operation = Object.freeze({
590
+ ...this.operationBase(request, sequence), kind: 'team/member-workspace-left',
591
+ data: Object.freeze({ workspaceId: request.workspaceId, memberId: member.memberId,
592
+ claims: Object.freeze(releasedClaims), activities, tasks, threads, inbox }),
593
+ });
594
+ await this.table.put(operation.operationId, operation);
595
+ this.apply(operation);
596
+ return this.committed(this.workspaceLeaveResult(operation));
597
+ });
598
+ }
579
599
  /**
580
600
  * Archive one Channel: hidden from every surface, facts kept recoverable.
581
601
  * Every active Claim on the Channel's Threads releases with one public
@@ -620,12 +640,6 @@ export class AgentTeamLedger {
620
640
  }
621
641
  sendMessage(request) {
622
642
  return this.enqueue(async () => {
623
- const recipients = this.normalizeRecipients(request.actor, request.recipients);
624
- const existing = this.state.byRequest.get(request.requestId);
625
- if (existing !== undefined) {
626
- this.assertSameMessage(existing, request, recipients);
627
- return this.resolved(this.messageResult(existing));
628
- }
629
643
  const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
630
644
  const channel = this.requireActiveChannel(request.workspaceId, request.channelRef);
631
645
  if (actor.kind === 'member')
@@ -633,6 +647,15 @@ export class AgentTeamLedger {
633
647
  const body = request.body.trim();
634
648
  if (body === '')
635
649
  throw new Error('message body must not be empty');
650
+ // Recipients are resolved before the request-id lookup: the stored
651
+ // operation carries the merged set, so a retry has to re-derive the same
652
+ // list for the collision check to prove it is the same request.
653
+ const recipients = this.mergeBodyMentions(actor.memberId, channel.channelRef, body, this.normalizeRecipients(request.actor, request.recipients));
654
+ const existing = this.state.byRequest.get(request.requestId);
655
+ if (existing !== undefined) {
656
+ this.assertSameMessage(existing, request, recipients);
657
+ return this.resolved(this.messageResult(existing));
658
+ }
636
659
  this.assertMentionTargets(channel, recipients);
637
660
  // Top-level Task creation is open to every actor: mentioned Members join
638
661
  // the new Thread as followers. Only existing-Thread invitations stay
@@ -667,32 +690,38 @@ export class AgentTeamLedger {
667
690
  }
668
691
  reply(request) {
669
692
  return this.enqueue(async () => {
670
- const recipients = this.normalizeRecipients(request.actor, request.recipients);
671
- const existing = this.state.byRequest.get(request.requestId);
672
- if (existing !== undefined) {
673
- this.assertSameReply(existing, request, recipients);
674
- return this.resolved(this.replyResult(existing));
675
- }
676
693
  const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
677
694
  const { task, thread, channelRef } = this.threadContextForActor(actor, request.workspaceId, request);
678
695
  const body = request.body.trim();
679
696
  if (body === '')
680
697
  throw new Error('message body must not be empty');
681
- this.assertMentionTargets(this.requireChannel(request.workspaceId, channelRef), recipients);
698
+ const channel = this.requireChannel(request.workspaceId, channelRef);
699
+ const recipients = this.mergeBodyMentions(actor.memberId, channelRef, body, this.normalizeRecipients(actor, request.recipients));
700
+ const existing = this.state.byRequest.get(request.requestId);
701
+ if (existing !== undefined) {
702
+ this.assertSameReply(existing, request, recipients);
703
+ return this.resolved(this.replyResult(existing, this.undeliverableRecipients(actor, thread.threadRef, recipients)));
704
+ }
705
+ this.assertMentionTargets(channel, recipients);
682
706
  const deferred = this.deferredThreadWrite(actor.memberId, task, thread, request.baseRevision);
683
707
  if (deferred !== undefined)
684
708
  return this.resolved(deferred);
685
709
  if (task?.resolution === 'closed')
686
710
  throw new Error(`Task '${task.taskRef}' is closed; reopen it before replying`);
687
- const unfollowedAgents = recipients.filter(memberId => this.state.members.has(memberId) && !this.isFollowing(thread.threadRef, memberId));
688
- if (unfollowedAgents.length > 0 && actor.kind === 'member') {
689
- return this.resolved(this.memberNotFollowing(request.workspaceId, channelRef, unfollowedAgents, task, thread));
690
- }
691
- if (unfollowedAgents.length > 0 && request.confirmationToken === undefined) {
692
- return this.resolved(this.issueConfirmation(request.actor, request.workspaceId, channelRef, body, recipients, task, thread));
711
+ // An Agent cannot invite into an existing Thread, so a body mention the
712
+ // Thread has never carried is dropped and reported back rather than
713
+ // failing the send. The Human keeps the confirmation step, which is what
714
+ // actually grants the invitation.
715
+ const undelivered = this.undeliverableRecipients(actor, thread.threadRef, recipients);
716
+ const delivered = undelivered.length === 0 ? recipients : recipients.filter(memberId => !undelivered.includes(memberId));
717
+ const unfollowedAgents = delivered.filter(memberId => this.state.members.has(memberId) && !this.isFollowing(thread.threadRef, memberId));
718
+ // A Member that once took part in this Thread is delivered to directly:
719
+ // re-joining a Thread it already belongs to is not an invitation.
720
+ if (unfollowedAgents.length > 0 && actor.kind === 'human' && request.confirmationToken === undefined) {
721
+ return this.resolved(this.issueConfirmation(request.actor, request.workspaceId, channelRef, body, delivered, task, thread));
693
722
  }
694
723
  if (request.confirmationToken !== undefined) {
695
- this.consumeConfirmation(request.confirmationToken, request.actor, request.workspaceId, channelRef, task, thread, body, recipients);
724
+ this.consumeConfirmation(request.confirmationToken, request.actor, request.workspaceId, channelRef, task, thread, body, delivered);
696
725
  }
697
726
  const sequence = this.nextSequence();
698
727
  const base = this.operationBase(request, sequence);
@@ -704,15 +733,15 @@ export class AgentTeamLedger {
704
733
  });
705
734
  const nextThread = Object.freeze({ ...thread, revision: sequence });
706
735
  const started = unfollowedAgents.map(memberId => this.startAttention(memberId, thread.threadRef, sequence));
707
- const inbox = this.messageInboxDelta(message, request.actor.memberId, recipients, started);
736
+ const inbox = this.messageInboxDelta(message, request.actor.memberId, delivered, started);
708
737
  const operation = Object.freeze({
709
738
  ...base, kind: 'team/thread-replied',
710
739
  data: Object.freeze({ workspaceId: request.workspaceId, baseRevision: request.baseRevision,
711
- mentions: recipients, message, ...(task === undefined ? {} : { task }), thread: nextThread, inbox }),
740
+ mentions: delivered, message, ...(task === undefined ? {} : { task }), thread: nextThread, inbox }),
712
741
  });
713
742
  await this.table.put(operation.operationId, operation);
714
743
  this.apply(operation);
715
- return this.committed(this.replyResult(operation));
744
+ return this.committed(this.replyResult(operation, undelivered));
716
745
  });
717
746
  }
718
747
  promoteThread(request) {
@@ -1027,7 +1056,7 @@ export class AgentTeamLedger {
1027
1056
  if (body === '')
1028
1057
  throw new Error('DM body must not be empty');
1029
1058
  const recipient = this.requireMember(request.recipientMemberId);
1030
- if (recipient.workspaceId !== request.workspaceId)
1059
+ if (!this.participatesIn(recipient.memberId, request.workspaceId))
1031
1060
  throw new Error(`Agent Member '${recipient.memberId}' is not in Workspace '${request.workspaceId}'`);
1032
1061
  if (recipient.state !== 'enabled')
1033
1062
  throw new Error(`Agent Member '${recipient.memberId}' is ${recipient.state}; DM delivery requires an enabled Member`);
@@ -1082,68 +1111,169 @@ export class AgentTeamLedger {
1082
1111
  }
1083
1112
  inbox(actor, request) {
1084
1113
  const authorized = this.assertActorForWorkspace(actor, request.workspaceId);
1085
- const limit = request.limit ?? 50;
1114
+ return this.inboxForWorkspaces(authorized, [request.workspaceId], request.limit);
1115
+ }
1116
+ memberInbox(actor, request) {
1117
+ const workspaces = this.workspacesOf(actor.memberId);
1118
+ for (const workspaceId of workspaces)
1119
+ this.assertActorForWorkspace(actor, workspaceId);
1120
+ return this.inboxForWorkspaces(actor, workspaces, request.limit);
1121
+ }
1122
+ inboxForWorkspaces(authorized, workspaceIds, requestedLimit) {
1123
+ const limit = requestedLimit ?? 50;
1086
1124
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1087
1125
  throw new Error('inbox limit must be an integer between 1 and 100');
1088
- // The Human direct-only slice serves the Client's mention queue: rows show
1089
- // only Threads with an unread mention, `totalUnreadCount` collapses to the
1090
- // direct total so a badge on this call cannot bypass follow unread, and
1091
- // each row carries its Channel display name plus the Thread's opening line
1092
- // so the Client renders without a Channel view per row. The ordinary
1093
- // (agent-facing) projection stays body-free.
1094
- const directOnly = request.directOnly === true;
1095
- const taskNumbers = directOnly ? this.taskNumbers(request.workspaceId) : undefined;
1126
+ // One unread slice serves every reader: Threads the reader follows or
1127
+ // holds a marker on, admitted whenever they hold an unread fact mentions
1128
+ // are counted inside it (`directCount`), never a separate admission rule.
1129
+ // Every row carries its rendering material — Channel display name, Task
1130
+ // ordinal, opening line, and the person the row's instant came from so the
1131
+ // Client renders a row without a Channel or Member view per row. Carrying
1132
+ // the opening line on the agent-facing projection is
1133
+ // safe: model-visible notifications read the refs and the facts, never this.
1134
+ // A Human reader additionally receives the separate 「最近活跃」 slice below;
1135
+ // `items` stays exactly the unread queue for every reader, so the badge
1136
+ // totals and the agent-facing `team_inbox` result cannot drift with it.
1137
+ const taskNumbers = new Map(workspaceIds.flatMap(workspaceId => [...this.taskNumbers(workspaceId)]));
1096
1138
  const items = [];
1097
- for (const threadRef of this.inboxCandidateThreads(authorized.memberId, directOnly)) {
1139
+ const unreadThreads = new Set();
1140
+ for (const threadRef of this.inboxCandidateThreads(authorized.memberId)) {
1098
1141
  const thread = this.state.threads.get(threadRef);
1099
1142
  if (thread === undefined)
1100
1143
  continue;
1101
1144
  const channelRef = this.channelRefForThread(thread.threadRef);
1102
1145
  if (channelRef === undefined)
1103
1146
  continue;
1104
- if (this.state.channels.get(channelRef)?.workspaceId !== request.workspaceId)
1147
+ const channel = this.state.channels.get(channelRef);
1148
+ if (channel === undefined || !workspaceIds.includes(channel.workspaceId))
1105
1149
  continue;
1150
+ // Archived Channels do not exist on Team API surfaces: their Threads
1151
+ // reach neither Inbox slice, matching ref resolution and the feed.
1152
+ if (channel.state === 'archived')
1153
+ continue;
1154
+ const workspaceId = channel.workspaceId;
1106
1155
  if (authorized.kind === 'member' && !this.isChannelMember(channelRef, authorized.memberId))
1107
1156
  continue;
1108
1157
  const unread = this.unreadFor(authorized.memberId, thread.threadRef);
1109
1158
  if (unread.length === 0)
1110
1159
  continue;
1160
+ unreadThreads.add(thread.threadRef);
1111
1161
  const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
1112
1162
  const directCount = unread.filter(item => item.direct).length;
1113
- if (directOnly && directCount === 0)
1114
- continue;
1115
1163
  const attention = this.attentionFor(authorized.memberId, thread.threadRef);
1116
1164
  // Same snapshot, same source as newestSequence: the instant hangs off
1117
1165
  // the newest unread fact itself, never a second lookup that could
1118
1166
  // observe a different commit between the two reads.
1119
1167
  const newest = unread.at(-1).fact;
1120
- const taskNumber = directOnly && task !== undefined ? taskNumbers?.get(task.taskRef) : undefined;
1121
- items.push(Object.freeze({ channelRef,
1122
- ...(directOnly ? { channelName: this.state.channels.get(channelRef)?.name ?? '' } : {}),
1168
+ const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
1169
+ items.push(Object.freeze({ workspaceId, channelRef,
1170
+ channelName: this.state.channels.get(channelRef)?.name ?? '',
1123
1171
  ...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
1124
1172
  unreadCount: unread.length, directCount,
1125
- ...(directOnly ? { previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body) } : {}),
1126
- newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt, ...(attention === undefined ? {} : { attention }) }));
1173
+ previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body),
1174
+ newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
1175
+ newestActor: this.inboxActorFor(newest), claimOwners: this.liveClaimOwners(task),
1176
+ ...(attention === undefined ? {} : { attention }) }));
1127
1177
  }
1128
1178
  items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
1129
1179
  const selected = items.slice(0, limit);
1130
- return Object.freeze({ items: Object.freeze(selected),
1131
- totalUnreadCount: directOnly
1132
- ? items.reduce((sum, item) => sum + item.directCount, 0)
1133
- : items.reduce((sum, item) => sum + item.unreadCount, 0),
1180
+ const recent = authorized.kind === 'human'
1181
+ ? this.recentInboxItems(authorized.memberId, workspaceIds[0], unreadThreads, taskNumbers)
1182
+ : Object.freeze([]);
1183
+ return Object.freeze({ items: Object.freeze(selected), recent,
1184
+ totalUnreadCount: items.reduce((sum, item) => sum + item.unreadCount, 0),
1134
1185
  totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
1135
1186
  }
1187
+ /**
1188
+ * The one person a row names: whoever committed the fact that row's instant
1189
+ * came from — a Message's sender, an activity's actor — resolved to the handle
1190
+ * the row draws. Resolving it here is what spares every Client row a Member
1191
+ * view of its own, and a Member the roster no longer names still reads as
1192
+ * themselves through their raw id rather than as nobody.
1193
+ */
1194
+ inboxActorFor(fact) {
1195
+ return this.memberActor(fact.kind === 'message' ? fact.message.sender : fact.activity.actor);
1196
+ }
1197
+ /** One Member as a row draws them: the id carries the identity hue, the handle the initial. */
1198
+ memberActor(memberId) {
1199
+ return Object.freeze({ memberId, name: this.state.members.get(memberId)?.handle ?? memberId });
1200
+ }
1201
+ /**
1202
+ * The people a Task still has on it, resolved the way a row draws them: owners
1203
+ * of its live Claims, in claim order, deduped. A released Claim is not work,
1204
+ * and a done or closed Task keeps its Claims as history rather than as
1205
+ * presence — deliberately the same rule the Channel feed applies to the same
1206
+ * Task, so 「谁在这个 Task 上」 never acquires a second definition.
1207
+ */
1208
+ liveClaimOwners(task) {
1209
+ if (task === undefined || (task.status !== 'in_progress' && task.status !== 'in_review'))
1210
+ return Object.freeze([]);
1211
+ const owners = [];
1212
+ for (const claim of this.claimsForTask(task.taskRef)) {
1213
+ if (claim.state === 'released' || owners.includes(claim.owner))
1214
+ continue;
1215
+ owners.push(claim.owner);
1216
+ }
1217
+ return Object.freeze(owners.map(owner => this.memberActor(owner)));
1218
+ }
1219
+ /**
1220
+ * The Human Inbox's 「最近活跃」 slice: the Threads this reader has written in
1221
+ * — the durable way back into work instead of a mention-only queue.
1222
+ * Participation is the whole admission rule: a reader who replied to somebody
1223
+ * else's Thread is here whether or not they follow it, and so is one who
1224
+ * started a Thread nobody has answered yet, because starting one is writing
1225
+ * its anchor. Attention deliberately plays no part — it decides what notifies
1226
+ * a reader (a reply does not implicitly follow a Thread), and reading it here
1227
+ * as well is what once hid every Thread a reader had replied to without
1228
+ * following. A Thread the reader later unfollowed stays: they did take part
1229
+ * in it, and unfollowing stops the notifications, not the record. Every
1230
+ * Thread still holding unread is excluded because the queue above already
1231
+ * carries it, and the slice is newest-activity first, bounded by
1232
+ * `RECENT_INBOX_LIMIT`. Rows are the same shape as unread rows with every
1233
+ * count at zero — they are the same row to render.
1234
+ */
1235
+ recentInboxItems(memberId, workspaceId, unreadThreads, taskNumbers) {
1236
+ const recent = [];
1237
+ for (const threadRef of this.state.threadsByWriter.get(memberId) ?? []) {
1238
+ if (unreadThreads.has(threadRef))
1239
+ continue;
1240
+ const thread = this.state.threads.get(threadRef);
1241
+ if (thread === undefined)
1242
+ continue;
1243
+ const channelRef = this.channelRefForThread(thread.threadRef);
1244
+ if (channelRef === undefined)
1245
+ continue;
1246
+ const channel = this.state.channels.get(channelRef);
1247
+ if (channel === undefined || channel.workspaceId !== workspaceId)
1248
+ continue;
1249
+ // Archived Channels do not exist on Team API surfaces, so their Threads
1250
+ // are no way back into work either — participation ends at archival.
1251
+ if (channel.state === 'archived')
1252
+ continue;
1253
+ const facts = this.state.factsByThread.get(thread.threadRef) ?? [];
1254
+ const newest = facts.at(-1);
1255
+ if (newest === undefined)
1256
+ continue;
1257
+ const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
1258
+ const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
1259
+ recent.push(Object.freeze({ workspaceId, channelRef,
1260
+ channelName: this.state.channels.get(channelRef)?.name ?? '',
1261
+ ...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
1262
+ unreadCount: 0, directCount: 0,
1263
+ previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body),
1264
+ newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
1265
+ newestActor: this.inboxActorFor(newest), claimOwners: this.liveClaimOwners(task) }));
1266
+ }
1267
+ recent.sort((left, right) => right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
1268
+ return Object.freeze(recent.slice(0, RECENT_INBOX_LIMIT));
1269
+ }
1136
1270
  /**
1137
1271
  * Threads that can hold unread facts for one reader, from the per-reader
1138
1272
  * derived indexes: Attention follows plus direct and activity markers.
1139
1273
  * Every unread source is covered — ordinary unread requires Attention, and
1140
- * marker unread requires a marker — so no full Thread scan is needed. The
1141
- * direct-only slice needs only the direct-marker threads, since a row
1142
- * requires at least one direct unread.
1274
+ * marker unread requires a marker — so no full Thread scan is needed.
1143
1275
  */
1144
- inboxCandidateThreads(memberId, directOnly) {
1145
- if (directOnly)
1146
- return this.state.directMarkersByMember.get(memberId)?.keys() ?? [];
1276
+ inboxCandidateThreads(memberId) {
1147
1277
  const refs = new Set();
1148
1278
  for (const source of [this.state.attentionThreadsByMember.get(memberId), this.state.directMarkersByMember.get(memberId), this.state.activityMarkersByMember.get(memberId)]) {
1149
1279
  for (const threadRef of source?.keys() ?? [])
@@ -1154,9 +1284,10 @@ export class AgentTeamLedger {
1154
1284
  /** Model-visible notification material derived from the recipient's current durable unread state. */
1155
1285
  notificationFacts(memberId, request) {
1156
1286
  const member = this.requireMember(memberId);
1157
- if (member.workspaceId !== request.workspaceId)
1287
+ if (request !== undefined && !this.participatesIn(memberId, request.workspaceId))
1158
1288
  throw new Error('Member cannot inspect another Workspace');
1159
- const inbox = this.inbox({ kind: 'member', memberId, handle: member.handle }, request);
1289
+ const actor = { kind: 'member', memberId, handle: member.handle };
1290
+ const inbox = request === undefined ? this.memberInbox(actor, {}) : this.inbox(actor, request);
1160
1291
  return Object.freeze(inbox.items.map(item => Object.freeze({ item,
1161
1292
  facts: this.unreadFor(memberId, item.thread.threadRef) })));
1162
1293
  }
@@ -1278,7 +1409,7 @@ export class AgentTeamLedger {
1278
1409
  throw new Error('cursor must be a non-negative integer sequence');
1279
1410
  if (memberId !== undefined) {
1280
1411
  const member = this.requireMember(memberId);
1281
- if (member.workspaceId !== request.workspaceId)
1412
+ if (!this.participatesIn(member.memberId, request.workspaceId))
1282
1413
  throw new Error('Member cannot view another Workspace');
1283
1414
  }
1284
1415
  if (request.channelRef !== undefined) {
@@ -1361,6 +1492,7 @@ export class AgentTeamLedger {
1361
1492
  const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
1362
1493
  const facts = this.state.factsByThread.get(thread.threadRef) ?? [];
1363
1494
  return Object.freeze({ message, mentions: fact.mentions, ...(task === undefined ? {} : { task, taskNumber: taskNumbers.get(task.taskRef) ?? 0 }), thread,
1495
+ claimOwners: this.liveClaimOwners(task),
1364
1496
  messageCount: this.state.messageCountByThread.get(thread.threadRef) ?? 0,
1365
1497
  lastActivityAt: facts.at(-1)?.occurredAt ?? '' });
1366
1498
  });
@@ -1368,6 +1500,8 @@ export class AgentTeamLedger {
1368
1500
  const nextCursor = selected.length === 0 ? cursor : direction === 'before' ? selected[0].sequence : selected.at(-1).sequence;
1369
1501
  return Object.freeze({
1370
1502
  humanMemberId: initialization.data.humanMemberId,
1503
+ workspaces: Object.freeze(memberId === undefined ? [] : this.workspacesOfFrom(this.state, memberId)
1504
+ .map(workspaceId => Object.freeze({ workspaceId, default: workspaceId === this.state.members.get(memberId)?.workspaceId }))),
1371
1505
  channels: Object.freeze(channels),
1372
1506
  members: Object.freeze([...this.state.memberships.entries()].filter(([channelRef]) => channelRefs.has(channelRef)).flatMap(([channelRef, ids]) => [...ids].filter(id => {
1373
1507
  const state = this.state.members.get(id)?.state;
@@ -1446,6 +1580,10 @@ export class AgentTeamLedger {
1446
1580
  }
1447
1581
  return scopes;
1448
1582
  }
1583
+ /** One workspace change scope per Workspace the Member participates in — member-level commits wake every panel that lists it. */
1584
+ memberWorkspaceScopes(memberId) {
1585
+ return [...(this.state.participations.get(memberId) ?? [])].map(workspaceId => ({ kind: 'workspace', workspaceId }));
1586
+ }
1449
1587
  /**
1450
1588
  * Durable position of the newest shared-projection commit: the version every
1451
1589
  * change waiter outside the presence scope observes. It only moves when a
@@ -1474,9 +1612,13 @@ export class AgentTeamLedger {
1474
1612
  case 'team/member-session-rolled-over':
1475
1613
  case 'team/member-updated':
1476
1614
  case 'team/member-removed':
1477
- return [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
1615
+ return this.memberWorkspaceScopes(operation.data.member.memberId);
1478
1616
  case 'team/member-archived':
1479
- return this.withReleasedActivityScopes([{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }], operation.data.tasks, operation.data.activities);
1617
+ return this.withReleasedActivityScopes(this.memberWorkspaceScopes(operation.data.member.memberId), operation.data.tasks, operation.data.activities);
1618
+ case 'team/member-workspace-joined':
1619
+ return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }];
1620
+ case 'team/member-workspace-left':
1621
+ return this.withReleasedActivityScopes([{ kind: 'workspace', workspaceId: operation.data.workspaceId }], operation.data.tasks, operation.data.activities);
1480
1622
  case 'team/channel-member-added':
1481
1623
  return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channelRef }];
1482
1624
  case 'team/channel-member-removed':
@@ -1539,6 +1681,8 @@ export class AgentTeamLedger {
1539
1681
  for (const follower of this.state.attentionByThread.get(threadRef) ?? [])
1540
1682
  members.add(follower);
1541
1683
  }
1684
+ if (operation.kind === 'team/member-workspace-joined' || operation.kind === 'team/member-workspace-left')
1685
+ members.add(operation.data.memberId);
1542
1686
  if (operation.actor.kind === 'member')
1543
1687
  members.add(operation.actor.memberId);
1544
1688
  return [...members];
@@ -1560,11 +1704,13 @@ export class AgentTeamLedger {
1560
1704
  case 'team/channel-archived':
1561
1705
  case 'team/member-removed':
1562
1706
  case 'team/member-archived':
1707
+ case 'team/member-workspace-left':
1563
1708
  return operation.data.activities.map(activity => activity.threadRef);
1564
1709
  case 'team/initialized':
1565
1710
  case 'team/channel-created':
1566
1711
  case 'team/channel-updated':
1567
1712
  case 'team/member-added':
1713
+ case 'team/member-workspace-joined':
1568
1714
  case 'team/member-suspended':
1569
1715
  case 'team/member-resumed':
1570
1716
  case 'team/member-session-restarted':
@@ -1646,7 +1792,7 @@ export class AgentTeamLedger {
1646
1792
  throw new Error('invalid initial Channel members');
1647
1793
  for (const memberId of memberIds) {
1648
1794
  const member = projection.members.get(memberId);
1649
- if (member === undefined || member.workspaceId !== channel.workspaceId || member.state !== 'enabled')
1795
+ if (member === undefined || !this.participatesInFrom(projection, member.memberId, channel.workspaceId) || member.state !== 'enabled')
1650
1796
  throw new Error('invalid initial Channel Member');
1651
1797
  }
1652
1798
  return;
@@ -1744,10 +1890,10 @@ export class AgentTeamLedger {
1744
1890
  || operation.data.member.presetId !== prior.presetId
1745
1891
  || operation.data.member.privateMemoryPath !== prior.privateMemoryPath)
1746
1892
  throw new Error('invalid Member update');
1747
- // The renamed handle must stay unique among the workspace's other live Members.
1893
+ // The renamed handle must stay unique among the live Members sharing any Workspace participation.
1748
1894
  const normalized = operation.data.member.handle.normalize('NFKC').trim().toLowerCase();
1749
1895
  for (const other of projection.members.values()) {
1750
- if (other.memberId !== prior.memberId && other.state !== 'inactive' && other.workspaceId === prior.workspaceId
1896
+ if (other.memberId !== prior.memberId && other.state !== 'inactive' && this.participationOverlapFrom(projection, other.memberId, prior.memberId)
1751
1897
  && other.handle.normalize('NFKC').trim().toLowerCase() === normalized)
1752
1898
  throw new Error('invalid Member update handle');
1753
1899
  }
@@ -1757,7 +1903,7 @@ export class AgentTeamLedger {
1757
1903
  assertHuman();
1758
1904
  const channel = projection.channels.get(operation.data.channelRef);
1759
1905
  const member = projection.members.get(operation.data.memberId);
1760
- if (channel === undefined || member === undefined || member.workspaceId !== channel.workspaceId || operation.data.workspaceId !== channel.workspaceId || projection.memberships.get(channel.channelRef)?.has(member.memberId))
1906
+ if (channel === undefined || member === undefined || !this.participatesInFrom(projection, member.memberId, channel.workspaceId) || operation.data.workspaceId !== channel.workspaceId || projection.memberships.get(channel.channelRef)?.has(member.memberId))
1761
1907
  throw new Error('invalid Channel membership');
1762
1908
  return;
1763
1909
  }
@@ -1766,12 +1912,32 @@ export class AgentTeamLedger {
1766
1912
  const channel = projection.channels.get(operation.data.channelRef);
1767
1913
  const member = projection.members.get(operation.data.memberId);
1768
1914
  if (channel === undefined || member === undefined || operation.data.workspaceId !== channel.workspaceId
1769
- || member.workspaceId !== channel.workspaceId || !projection.memberships.get(channel.channelRef)?.has(member.memberId))
1915
+ || !this.participatesInFrom(projection, member.memberId, channel.workspaceId) || !projection.memberships.get(channel.channelRef)?.has(member.memberId))
1770
1916
  throw new Error('invalid Channel membership removal');
1771
1917
  const threadRefs = new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === channel.channelRef));
1772
1918
  this.validateReleaseCleanup(operation.data, projection, member.memberId, threadRefs, operation.sequence, refs);
1773
1919
  return;
1774
1920
  }
1921
+ if (operation.kind === 'team/member-workspace-joined') {
1922
+ assertHuman();
1923
+ const member = projection.members.get(operation.data.memberId);
1924
+ if (member === undefined || member.state === 'inactive' || member.state === 'archived'
1925
+ || this.participatesInFrom(projection, member.memberId, operation.data.workspaceId))
1926
+ throw new Error('invalid Workspace join');
1927
+ this.assertHandleAvailableFrom(projection, operation.data.workspaceId, member.handle);
1928
+ return;
1929
+ }
1930
+ if (operation.kind === 'team/member-workspace-left') {
1931
+ assertHuman();
1932
+ const member = projection.members.get(operation.data.memberId);
1933
+ if (member === undefined || member.state === 'inactive' || member.state === 'archived'
1934
+ || operation.data.workspaceId === member.workspaceId
1935
+ || !this.participatesInFrom(projection, member.memberId, operation.data.workspaceId))
1936
+ throw new Error('invalid Workspace leave');
1937
+ const threadRefs = this.workspaceThreadRefsFrom(projection, operation.data.workspaceId);
1938
+ this.validateReleaseCleanup(operation.data, projection, member.memberId, threadRefs, operation.sequence, refs);
1939
+ return;
1940
+ }
1775
1941
  if (operation.kind === 'team/channel-archived') {
1776
1942
  assertHuman();
1777
1943
  const prior = projection.channels.get(operation.data.channel.channelRef);
@@ -1839,7 +2005,10 @@ export class AgentTeamLedger {
1839
2005
  if (isThreadReadSnapshot(data)) {
1840
2006
  // The pre-receipt form froze the whole picture, so its expected value
1841
2007
  // is re-derived the same way it was written: from this record's prior
1842
- // projection only, never from the live one.
2008
+ // projection only, never from the live one. That shape stays frozen —
2009
+ // a field added after it, like the orientation count, is not compared
2010
+ // here, because no stored legacy record carries it and inventing one
2011
+ // would reject every upgrade of a ledger written before the field.
1843
2012
  const expected = this.prepareReadFrom(projection, data.memberId, data.workspaceId, target);
1844
2013
  const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId,
1845
2014
  ...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
@@ -2030,7 +2199,7 @@ export class AgentTeamLedger {
2030
2199
  if (operation.kind === 'team/dm-sent') {
2031
2200
  const sender = assertMember();
2032
2201
  const recipient = projection.members.get(operation.data.recipientMemberId);
2033
- if (recipient === undefined || recipient.workspaceId !== operation.data.workspaceId
2202
+ if (recipient === undefined || !this.participatesInFrom(projection, recipient.memberId, operation.data.workspaceId)
2034
2203
  || recipient.state !== 'enabled' || recipient.memberId === AGENT_TEAM_HUMAN_MEMBER_ID
2035
2204
  || operation.data.senderMemberId !== sender.memberId
2036
2205
  || operation.data.recipientMemberId === sender.memberId
@@ -2196,7 +2365,7 @@ export class AgentTeamLedger {
2196
2365
  // Top-level mentions are open to every actor; only a Member reply may not
2197
2366
  // pull an unfollowed Member into an existing Thread.
2198
2367
  if (operation.actor.kind === 'member' && operation.kind === 'team/thread-replied'
2199
- && mentionedAgents.some(memberId => !this.isFollowingFrom(projection, message.threadRef, memberId))) {
2368
+ && mentionedAgents.some(memberId => !this.everParticipatedFrom(projection, message.threadRef, memberId))) {
2200
2369
  throw new Error('invalid Agent mention projection');
2201
2370
  }
2202
2371
  const started = operation.kind === 'team/message-sent'
@@ -2221,8 +2390,9 @@ export class AgentTeamLedger {
2221
2390
  if (memberId === AGENT_TEAM_HUMAN_MEMBER_ID)
2222
2391
  return true;
2223
2392
  const member = projection.members.get(memberId);
2393
+ const channelWorkspaceId = projection.channels.get(channelRef)?.workspaceId;
2224
2394
  return member !== undefined && member.state !== 'inactive' && member.state !== 'archived'
2225
- && projection.channels.get(channelRef)?.workspaceId === member.workspaceId
2395
+ && channelWorkspaceId !== undefined && this.participatesInFrom(projection, memberId, channelWorkspaceId)
2226
2396
  && this.isChannelMemberFrom(projection, channelRef, memberId);
2227
2397
  }
2228
2398
  apply(operation) {
@@ -2247,6 +2417,9 @@ export class AgentTeamLedger {
2247
2417
  }
2248
2418
  if (operation.kind === 'team/member-added') {
2249
2419
  target.members.set(operation.data.member.memberId, operation.data.member);
2420
+ // Creation seeds the first Workspace participation; member.workspaceId
2421
+ // stays the default Workspace the Session roots in, immutable after add.
2422
+ target.participations.set(operation.data.member.memberId, new Set([operation.data.member.workspaceId]));
2250
2423
  for (const channelRef of operation.data.channelRefs)
2251
2424
  this.addMembership(target, channelRef, operation.data.member.memberId);
2252
2425
  return;
@@ -2305,6 +2478,23 @@ export class AgentTeamLedger {
2305
2478
  this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2306
2479
  return;
2307
2480
  }
2481
+ if (operation.kind === 'team/member-workspace-joined') {
2482
+ const workspaces = target.participations.get(operation.data.memberId) ?? new Set();
2483
+ workspaces.add(operation.data.workspaceId);
2484
+ target.participations.set(operation.data.memberId, workspaces);
2485
+ return;
2486
+ }
2487
+ if (operation.kind === 'team/member-workspace-left') {
2488
+ target.participations.get(operation.data.memberId)?.delete(operation.data.workspaceId);
2489
+ // Departure ends Channel memberships inside the left Workspace — unlike
2490
+ // archival, which keeps them hidden for a possible restore.
2491
+ for (const channel of target.channels.values()) {
2492
+ if (channel.workspaceId === operation.data.workspaceId)
2493
+ target.memberships.get(channel.channelRef)?.delete(operation.data.memberId);
2494
+ }
2495
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2496
+ return;
2497
+ }
2308
2498
  if (operation.kind === 'team/channel-archived') {
2309
2499
  // Archival keeps Memberships: hidden state, not departure — a future
2310
2500
  // restore returns every Member to the Channel. Visibility filters by
@@ -2420,6 +2610,11 @@ export class AgentTeamLedger {
2420
2610
  // The ref index is written in this same step, so it is exactly the set of
2421
2611
  // replayed Messages: a validator lookup can never reach a later record.
2422
2612
  target.messagesByRef.set(message.messageRef, message);
2613
+ // Participation is indexed per writer as the fact lands, so the Human
2614
+ // recent slice asks who wrote where without scanning the whole ledger.
2615
+ const written = target.threadsByWriter.get(message.sender) ?? new Set();
2616
+ written.add(message.threadRef);
2617
+ target.threadsByWriter.set(message.sender, written);
2423
2618
  }
2424
2619
  appendActivityFact(target, activity, occurredAt) {
2425
2620
  const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
@@ -2596,9 +2791,21 @@ export class AgentTeamLedger {
2596
2791
  const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...receipt.unread.slice(0, 20)]
2597
2792
  .sort((left, right) => left.fact.sequence - right.fact.sequence);
2598
2793
  const remainingUnreadCount = this.remainingUnreadAfter(projection, memberId, receipt);
2794
+ // What this bounded read leaves behind it: the Thread facts that precede the
2795
+ // watermark it reaches, which is where this reader now stands in the Thread
2796
+ // and how much of it that position has never shown it. Anchoring on the
2797
+ // watermark rather than on the response's own oldest fact is what keeps a
2798
+ // returning reader honest — the background window starts at the Thread's
2799
+ // first fact whenever the Thread is short enough to fit it, so measuring
2800
+ // from the response would report nothing behind a reader that has in fact
2801
+ // never read the Thread. Derived from the projection the read resolved
2802
+ // against, like every other field of this picture, so a replay re-derives
2803
+ // the same number.
2804
+ const earlierFactCount = this.threadFactsFrom(projection, thread.threadRef)
2805
+ .filter(fact => fact.sequence < receipt.readThroughSequence).length;
2599
2806
  return Object.freeze({ ...receipt, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
2600
2807
  anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
2601
- facts: Object.freeze(combined), remainingUnreadCount });
2808
+ facts: Object.freeze(combined), remainingUnreadCount, earlierFactCount });
2602
2809
  }
2603
2810
  readFactFrom(projection, memberId, fact, unread) {
2604
2811
  return Object.freeze({ fact, unread, direct: fact.kind === 'message'
@@ -2849,15 +3056,11 @@ export class AgentTeamLedger {
2849
3056
  if (memberId === AGENT_TEAM_HUMAN_MEMBER_ID)
2850
3057
  continue;
2851
3058
  const member = this.requireMember(memberId);
2852
- if (member.state === 'inactive' || member.state === 'archived' || member.workspaceId !== channel.workspaceId || !this.isChannelMember(channel.channelRef, memberId)) {
3059
+ if (member.state === 'inactive' || member.state === 'archived' || !this.participatesIn(member.memberId, channel.workspaceId) || !this.isChannelMember(channel.channelRef, memberId)) {
2853
3060
  throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${channel.channelRef}'`);
2854
3061
  }
2855
3062
  }
2856
3063
  }
2857
- memberNotFollowing(workspaceId, channelRef, memberIds, task, thread) {
2858
- return Object.freeze({ kind: 'member_not_following', workspaceId, channelRef, memberIds: Object.freeze(memberIds),
2859
- ...(task === undefined ? {} : { taskRef: task.taskRef }), ...(thread === undefined ? {} : { threadRef: thread.threadRef, revision: thread.revision }) });
2860
- }
2861
3064
  unreadRequired(task, thread, unread) {
2862
3065
  return Object.freeze({ kind: 'unread_required', ...(task === undefined ? {} : { taskRef: task.taskRef }), threadRef: thread.threadRef,
2863
3066
  revision: thread.revision, unreadCount: unread.length, directCount: unread.filter(item => item.direct).length });
@@ -2917,10 +3120,11 @@ export class AgentTeamLedger {
2917
3120
  return undefined;
2918
3121
  }
2919
3122
  /** Commit projections shared by the Message-sent and Thread-replied results. */
2920
- committedMessageResult(operation) {
3123
+ committedMessageResult(operation, undelivered = []) {
2921
3124
  return { kind: 'committed', receipt: this.receipt(operation), message: operation.data.message,
2922
3125
  ...(operation.data.task === undefined ? {} : { task: operation.data.task }), thread: operation.data.thread, attention: operation.data.inbox.attention.set,
2923
- directMarkers: operation.data.inbox.directMarkers.added };
3126
+ directMarkers: operation.data.inbox.directMarkers.added,
3127
+ ...(undelivered.length === 0 ? {} : { undeliveredMentions: undelivered }) };
2924
3128
  }
2925
3129
  threadAnchor(threadRef) {
2926
3130
  return this.threadAnchorFrom(this.state, threadRef);
@@ -2997,7 +3201,7 @@ export class AgentTeamLedger {
2997
3201
  return actor;
2998
3202
  }
2999
3203
  const member = this.assertMemberActor(actor);
3000
- if (member.workspaceId !== workspaceId)
3204
+ if (!this.participatesIn(member.memberId, workspaceId))
3001
3205
  throw new Error('Member cannot mutate another Workspace');
3002
3206
  return actor;
3003
3207
  }
@@ -3174,10 +3378,50 @@ export class AgentTeamLedger {
3174
3378
  channelThreadRefsFrom(projection, channelRef) {
3175
3379
  return new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === channelRef));
3176
3380
  }
3381
+ /** The Workspace authorization question: does this Member participate in this Workspace. */
3382
+ participatesIn(memberId, workspaceId) {
3383
+ return this.participatesInFrom(this.state, memberId, workspaceId);
3384
+ }
3385
+ participatesInFrom(projection, memberId, workspaceId) {
3386
+ return projection.participations.get(memberId)?.has(workspaceId) === true;
3387
+ }
3388
+ /** Every Workspace the Member participates in: the default first, then the rest sorted. */
3389
+ workspacesOf(memberId) {
3390
+ return Object.freeze(this.workspacesOfFrom(this.state, memberId));
3391
+ }
3392
+ workspacesOfFrom(projection, memberId) {
3393
+ const member = projection.members.get(memberId);
3394
+ if (member === undefined)
3395
+ return [];
3396
+ const rest = [...(projection.participations.get(memberId) ?? new Set())]
3397
+ .filter(workspaceId => workspaceId !== member.workspaceId).sort();
3398
+ return [member.workspaceId, ...rest];
3399
+ }
3400
+ /** Whether two Members share at least one Workspace participation — the handle-uniqueness scope. */
3401
+ participationOverlapFrom(projection, leftMemberId, rightMemberId) {
3402
+ const left = projection.participations.get(leftMemberId);
3403
+ const right = projection.participations.get(rightMemberId);
3404
+ if (left === undefined || right === undefined)
3405
+ return false;
3406
+ for (const workspaceId of left)
3407
+ if (right.has(workspaceId))
3408
+ return true;
3409
+ return false;
3410
+ }
3411
+ /** Every Thread in the Workspace — the scope a Workspace leave replays its cleanup against. */
3412
+ workspaceThreadRefs(workspaceId) {
3413
+ return this.workspaceThreadRefsFrom(this.state, workspaceId);
3414
+ }
3415
+ workspaceThreadRefsFrom(projection, workspaceId) {
3416
+ return new Set([...projection.threads.keys()].filter(threadRef => {
3417
+ const channelRef = this.channelRefForThreadFrom(projection, threadRef);
3418
+ return channelRef !== undefined && projection.channels.get(channelRef)?.workspaceId === workspaceId;
3419
+ }));
3420
+ }
3177
3421
  assertJoinableMember(workspaceId, memberId) {
3178
3422
  const member = this.requireMember(memberId);
3179
- if (member.workspaceId !== workspaceId)
3180
- throw new Error(`Agent Member '${memberId}' does not belong to Workspace '${workspaceId}'`);
3423
+ if (!this.participatesIn(memberId, workspaceId))
3424
+ throw new Error(`Agent Member '${memberId}' does not participate in Workspace '${workspaceId}'`);
3181
3425
  if (member.state !== 'enabled')
3182
3426
  throw new Error(`Agent Member '${memberId}' is ${member.state}; only enabled Members can join a Channel`);
3183
3427
  }
@@ -3193,13 +3437,78 @@ export class AgentTeamLedger {
3193
3437
  throw new Error(`${label} contains duplicate Member refs`);
3194
3438
  return Object.freeze([...unique].sort());
3195
3439
  }
3440
+ /**
3441
+ * Names a Message body may address in one Channel: every live Member of that
3442
+ * Channel plus the Human. A name outside this set stays prose, which is what
3443
+ * keeps an incidental name-drop from reaching someone the Channel cannot
3444
+ * deliver to.
3445
+ */
3446
+ mentionCandidatesFor(channelRef) {
3447
+ const candidates = [{ memberId: AGENT_TEAM_HUMAN_MEMBER_ID, handle: AGENT_TEAM_HUMAN_HANDLE }];
3448
+ for (const member of this.state.members.values()) {
3449
+ if (member.state === 'inactive' || member.state === 'archived')
3450
+ continue;
3451
+ if (!this.isChannelMember(channelRef, member.memberId))
3452
+ continue;
3453
+ candidates.push({ memberId: member.memberId, handle: member.handle });
3454
+ }
3455
+ return Object.freeze(candidates);
3456
+ }
3457
+ /**
3458
+ * Merge the `@Handle` mentions authored in `body` into an explicit recipient
3459
+ * set. Body mentions are the primary channel now: an Agent has no recipient
3460
+ * parameter to forget, and the same scan serves Human input typed by hand.
3461
+ * The result stays a plain recipient set, so every downstream projection —
3462
+ * delivery markers, chip rendering, confirmation — is unchanged.
3463
+ */
3464
+ mergeBodyMentions(sender, channelRef, body, explicit) {
3465
+ const candidates = this.mentionCandidatesFor(channelRef);
3466
+ const resolution = resolveBodyMentions(body, candidates, sender);
3467
+ // `@all` stands for its expansion as of this write: the Member set is
3468
+ // snapshotted into the recipient list, so a later roster change cannot
3469
+ // retroactively alter what this operation delivered.
3470
+ const authored = resolution.all ? candidates.map(candidate => candidate.memberId) : resolution.memberIds;
3471
+ const merged = new Set([...explicit, ...authored]);
3472
+ merged.delete(sender);
3473
+ return Object.freeze([...merged].sort());
3474
+ }
3475
+ /**
3476
+ * Whether one Member has ever held Attention on one Thread. Committed Inbox
3477
+ * deltas append a follow/unfollow observation, and the live index covers the
3478
+ * Thread-creating Message whose initial Attention is never observed — so the
3479
+ * two together answer "was this Member ever part of this Thread" without
3480
+ * adding a second durable authority.
3481
+ */
3482
+ everParticipated(threadRef, memberId) {
3483
+ return this.everParticipatedFrom(this.state, threadRef, memberId);
3484
+ }
3485
+ everParticipatedFrom(projection, threadRef, memberId) {
3486
+ if (this.isFollowingFrom(projection, threadRef, memberId))
3487
+ return true;
3488
+ return (projection.observationsByThread.get(threadRef) ?? []).some(observation => observation.memberId === memberId);
3489
+ }
3490
+ /**
3491
+ * Split the Agent recipients an existing Thread cannot deliver to: those the
3492
+ * body named that the Thread has never carried. The send still commits — a
3493
+ * text mention must never fail the write — and the author is told through the
3494
+ * result, because inviting a Member into an existing Thread stays a Human
3495
+ * decision.
3496
+ */
3497
+ undeliverableRecipients(actor, threadRef, recipients) {
3498
+ if (actor.kind !== 'member')
3499
+ return Object.freeze([]);
3500
+ return Object.freeze(recipients.filter(memberId => this.state.members.has(memberId) && !this.everParticipated(threadRef, memberId)));
3501
+ }
3196
3502
  normalizeDirection(direction) {
3197
3503
  return direction.normalize('NFKC').trim().replace(/\s+/gu, ' ').toLowerCase();
3198
3504
  }
3199
3505
  assertHandleAvailable(workspaceId, handle, exceptMemberId) {
3506
+ this.assertHandleAvailableFrom(this.state, workspaceId, handle, exceptMemberId);
3507
+ }
3508
+ assertHandleAvailableFrom(projection, workspaceId, handle, exceptMemberId) {
3200
3509
  const normalized = handle.normalize('NFKC').trim().toLowerCase();
3201
- if ([...this.state.members.values()].some(member => member.memberId !== exceptMemberId && member.state !== 'inactive'
3202
- && member.workspaceId === workspaceId
3510
+ if ([...projection.members.values()].some(member => member.memberId !== exceptMemberId && member.state !== 'inactive'
3511
+ && this.participatesInFrom(projection, member.memberId, workspaceId)
3203
3512
  && member.handle.normalize('NFKC').trim().toLowerCase() === normalized)) {
3204
3513
  throw new Error(`Agent Member handle '${handle}' is already active in Workspace '${workspaceId}'`);
3205
3514
  }
@@ -3301,6 +3610,16 @@ export class AgentTeamLedger {
3301
3610
  || operation.data.workspaceId !== request.workspaceId || operation.data.channelRef !== request.channelRef || operation.data.memberId !== request.memberId)
3302
3611
  this.throwRequestCollision(request.requestId);
3303
3612
  }
3613
+ assertSameWorkspaceJoin(operation, request) {
3614
+ if (operation.kind !== 'team/member-workspace-joined' || !this.sameActor(operation.actor, request.actor)
3615
+ || operation.data.workspaceId !== request.workspaceId || operation.data.memberId !== request.memberId)
3616
+ this.throwRequestCollision(request.requestId);
3617
+ }
3618
+ assertSameWorkspaceLeave(operation, request) {
3619
+ if (operation.kind !== 'team/member-workspace-left' || !this.sameActor(operation.actor, request.actor)
3620
+ || operation.data.workspaceId !== request.workspaceId || operation.data.memberId !== request.memberId)
3621
+ this.throwRequestCollision(request.requestId);
3622
+ }
3304
3623
  assertSameChannelArchival(operation, request) {
3305
3624
  if (operation.kind !== 'team/channel-archived' || !this.sameActor(operation.actor, request.actor)
3306
3625
  || operation.data.workspaceId !== request.workspaceId || operation.data.channel.channelRef !== request.channelRef)
@@ -3456,6 +3775,13 @@ export class AgentTeamLedger {
3456
3775
  return Object.freeze({ receipt: this.receipt(operation), channelRef: operation.data.channelRef, memberId: operation.data.memberId,
3457
3776
  releasedClaims: operation.data.claims, removedAttention: operation.data.inbox.attention.removed });
3458
3777
  }
3778
+ workspaceJoinResult(operation) {
3779
+ return Object.freeze({ receipt: this.receipt(operation), memberId: operation.data.memberId, workspaceId: operation.data.workspaceId });
3780
+ }
3781
+ workspaceLeaveResult(operation) {
3782
+ return Object.freeze({ receipt: this.receipt(operation), memberId: operation.data.memberId, workspaceId: operation.data.workspaceId,
3783
+ releasedClaims: operation.data.claims, removedAttention: operation.data.inbox.attention.removed });
3784
+ }
3459
3785
  channelArchivalResult(operation) {
3460
3786
  return Object.freeze({ receipt: this.receipt(operation), channel: operation.data.channel,
3461
3787
  releasedClaims: operation.data.claims });
@@ -3463,8 +3789,8 @@ export class AgentTeamLedger {
3463
3789
  messageResult(operation) {
3464
3790
  return Object.freeze(this.committedMessageResult(operation));
3465
3791
  }
3466
- replyResult(operation) {
3467
- return Object.freeze(this.committedMessageResult(operation));
3792
+ replyResult(operation, undelivered = []) {
3793
+ return Object.freeze(this.committedMessageResult(operation, undelivered));
3468
3794
  }
3469
3795
  claimResult(operation) {
3470
3796
  const attention = operation.data.inbox.attention.set.find(candidate => candidate.memberId === operation.data.claim.owner);
@@ -3494,6 +3820,7 @@ export class AgentTeamLedger {
3494
3820
  return Object.freeze({ ...(prepared.task === undefined ? {} : { task: prepared.task }), thread: prepared.thread,
3495
3821
  claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions, facts: prepared.facts,
3496
3822
  readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
3823
+ earlierFactCount: prepared.earlierFactCount,
3497
3824
  ...(prepared.attention === undefined ? {} : { attention: prepared.attention }),
3498
3825
  consumedDirectMarkers: prepared.inbox.directMarkers.removed });
3499
3826
  }