@wowyuarm/dsh-agent-team 0.1.8 → 0.1.10

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 (81) hide show
  1. package/README.md +4 -11
  2. package/README.zh.md +4 -11
  3. package/package.json +54 -44
  4. package/packages/agent-team/README.md +7 -5
  5. package/packages/agent-team/README.zh.md +7 -4
  6. package/packages/agent-team/lib/attachments.js +14 -4
  7. package/packages/agent-team/lib/context-management.js +382 -0
  8. package/packages/agent-team/lib/context-projection.js +578 -0
  9. package/packages/agent-team/lib/context-source.js +212 -0
  10. package/packages/agent-team/lib/index.js +1272 -76
  11. package/packages/agent-team/lib/ledger.js +333 -44
  12. package/packages/agent-team/lib/member-context.js +6 -2
  13. package/packages/agent-team/lib/member-runtime.js +159 -4
  14. package/packages/agent-team/lib/member-time-context.js +126 -0
  15. package/packages/agent-team/lib/pressure-policy.js +199 -0
  16. package/packages/agent-team/lib/progress-nudge.js +12 -4
  17. package/packages/agent-team/lib/session-remediation.js +481 -0
  18. package/packages/agent-team/lib/spec.js +21 -2
  19. package/packages/agent-team/lib/time-format.js +56 -0
  20. package/packages/agent-team/lib/typert.host.js +168 -54
  21. package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
  22. package/packages/agent-team/lib/typert.remote-client.js +59 -25
  23. package/packages/agent-team/lib/types/attachments.d.ts +1 -1
  24. package/packages/agent-team/lib/types/attachments.d.ts.map +1 -1
  25. package/packages/agent-team/lib/types/context-management.d.ts +147 -0
  26. package/packages/agent-team/lib/types/context-management.d.ts.map +1 -0
  27. package/packages/agent-team/lib/types/context-projection.d.ts +219 -0
  28. package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -0
  29. package/packages/agent-team/lib/types/context-source.d.ts +117 -0
  30. package/packages/agent-team/lib/types/context-source.d.ts.map +1 -0
  31. package/packages/agent-team/lib/types/index.d.ts +300 -2
  32. package/packages/agent-team/lib/types/index.d.ts.map +1 -1
  33. package/packages/agent-team/lib/types/ledger.d.ts +119 -6
  34. package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
  35. package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
  36. package/packages/agent-team/lib/types/member-runtime.d.ts +30 -2
  37. package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
  38. package/packages/agent-team/lib/types/member-time-context.d.ts +83 -0
  39. package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -0
  40. package/packages/agent-team/lib/types/pressure-policy.d.ts +106 -0
  41. package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -0
  42. package/packages/agent-team/lib/types/progress-nudge.d.ts.map +1 -1
  43. package/packages/agent-team/lib/types/session-remediation.d.ts +149 -0
  44. package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -0
  45. package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
  46. package/packages/agent-team/lib/types/time-format.d.ts +28 -0
  47. package/packages/agent-team/lib/types/time-format.d.ts.map +1 -0
  48. package/packages/agent-team/lib/types/types/entities.d.ts +22 -1
  49. package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
  50. package/packages/agent-team/lib/types/types/operations.d.ts +38 -3
  51. package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
  52. package/packages/agent-team/lib/types/types/requests-results.d.ts +58 -1
  53. package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
  54. package/packages/agent-team/preset/team-member/agent.cordis.yml +27 -4
  55. package/packages/client-agent-team/lib/client.js +369 -352
  56. package/packages/client-agent-team/lib/client.js.map +1 -1
  57. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +1 -2
  58. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
  59. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +61 -41
  60. package/packages/client-agent-team/lib/types/client/TeamMessage.js +2 -2
  61. package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +4 -4
  62. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
  63. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
  64. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +2 -2
  65. package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
  66. package/packages/client-agent-team/lib/types/client/index.js +44 -16
  67. package/packages/client-agent-team/lib/types/client/locales.d.ts +0 -14
  68. package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
  69. package/packages/client-agent-team/lib/types/client/locales.js +0 -14
  70. package/packages/client-agent-team/lib/types/client/slots.d.ts +6 -1
  71. package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
  72. package/packages/tool-agent-team/README.md +11 -6
  73. package/packages/tool-agent-team/README.zh.md +11 -6
  74. package/packages/tool-agent-team/lib/context-tools.js +179 -0
  75. package/packages/tool-agent-team/lib/index.js +369 -58
  76. package/packages/tool-agent-team/lib/types/context-tools.d.ts +16 -0
  77. package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -0
  78. package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
  79. package/packages/agent-team/lib/auto-compaction.js +0 -201
  80. package/packages/agent-team/lib/types/auto-compaction.d.ts +0 -47
  81. package/packages/agent-team/lib/types/auto-compaction.d.ts.map +0 -1
@@ -46,25 +46,48 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent';
46
46
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
47
47
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
48
48
  import { scopeOf } from '@deepseek-ai/dsh-scope';
49
- import { SessionId } from '@deepseek-ai/dsh-session';
49
+ import { Session, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session';
50
+ import { SessionFormatUnsupportedError } from '@deepseek-ai/dsh-session-persistence';
50
51
  import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy';
51
52
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
52
53
  import { ATTACHMENT_MAX_BYTES, attachmentPayloadPath, attachmentsRoot, copyPathAttachment, newAttachmentId, readAttachment, sanitizeMediaType, sweepAttachmentCache, validatePathAttachment, writeAttachment } from "./attachments.js";
53
- import { acceptedTaskCompactionMembers, AutoCompactionCoordinator, PRE_COMPACTION_NOTICE_SUMMARY, preCompactionNoticeText } from "./auto-compaction.js";
54
+ import { PressurePolicyCoordinator } from "./pressure-policy.js";
55
+ import { ContextManagementCoordinator } from "./context-management.js";
56
+ import { AGENT_TEAM_PLUGIN_ID, createHandoffMessage } from "./context-source.js";
57
+ import { carriedInputOf, checkpointByRef, checkpointRefFor, foldContextProjection, isReminderNoticeSummary, timelineCandidates } from "./context-projection.js";
54
58
  import { AGENT_TEAM_HUMAN_MEMBER_ID, AgentTeamLedger, agentTeamHumanActor } from "./ledger.js";
55
- import { AGENT_TEAM_TOOL_NAMES, deepCopyCapabilities, MemberRuntime } from "./member-runtime.js";
59
+ import { AGENT_TEAM_TOOL_NAMES, deepCopyCapabilities, memberMemoryDirectoryName, MemberRuntime } from "./member-runtime.js";
56
60
  import { ProgressNudgeCoordinator } from "./progress-nudge.js";
57
61
  import { classifyRecoverableError, RecoveryCoordinator, RECOVERY_MAX_CONSECUTIVE_ERRORS } from "./recovery.js";
62
+ import { SessionRemediation, handoffAlreadyInLog } from "./session-remediation.js";
58
63
  import { agentTeamDomainSpec } from "./spec.js";
64
+ import { formatTeamTimestamp } from "./time-format.js";
59
65
  export { agentTeamDomainSpec, agentTeamOperationSchema } from "./spec.js";
60
66
  export { AGENT_TEAM_HUMAN_MEMBER_ID, AGENT_TEAM_INITIALIZE_REQUEST_ID } from "./ledger.js";
61
67
  export { AGENT_TEAM_TOOL_NAMES } from "./member-runtime.js";
62
68
  /** Process-stable marker carried by the final Team message tool definition. */
63
69
  export const AGENT_TEAM_PRESET_MARKER = Symbol.for('@wowyuarm/dsh-agent-team.preset');
64
- const AGENT_TEAM_PLUGIN_ID = '@wowyuarm/dsh-agent-team';
65
70
  const INBOX_NOTICE_SUMMARY = 'Team Inbox has unread work.';
66
71
  const RECOVERY_NOTICE_SUMMARY = 'Recovery: continue your interrupted work.';
67
72
  const ORPHANED_MEMBER_DIAGNOSTIC = 'Member preset composition was lost after a reload; its tools are unavailable. Resume rebuilds the member in place.';
73
+ /** Longest accepted model-supplied checkpoint display name. */
74
+ const MAX_CHECKPOINT_NAME_CHARS = 120;
75
+ /** Default and maximum number of timeline items one query returns. */
76
+ const DEFAULT_TIMELINE_LIMIT = 12;
77
+ const MAX_TIMELINE_LIMIT = 24;
78
+ /** Deepest ancestor lineage the timeline and seed resolution walk. */
79
+ const MAX_TIMELINE_ANCESTORS = 8;
80
+ /** Product pressure budget constants (see docs/team-collaboration.md). */
81
+ const CONTEXT_HARD_LIMIT_CAP = 256_000;
82
+ const CONTEXT_HANDOFF_AT_CAP = 200_000;
83
+ const CONTEXT_HANDOFF_RESERVE = 8_000;
84
+ const CONTEXT_SAFE_OUTPUT_RESERVE = 16_000;
85
+ /**
86
+ * Usage at or above which an acknowledged acceptance advises a fresh
87
+ * rollover instead of keeping the context; capped by the route's effective
88
+ * handoff budget so narrow routes get a proportionally earlier boundary.
89
+ */
90
+ const ACCEPT_TASK_BOUNDARY_THRESHOLD = 128_000;
68
91
  function sameChangeScope(left, right) {
69
92
  if (left.kind === 'workspace' && right.kind === 'workspace')
70
93
  return left.workspaceId === right.workspaceId;
@@ -79,6 +102,35 @@ export function markAgentTeamPreset(definition) {
79
102
  Object.defineProperty(definition, AGENT_TEAM_PRESET_MARKER, { value: true });
80
103
  return definition;
81
104
  }
105
+ /**
106
+ * Whether the running dsh loads workspace packages from source via tsx.
107
+ *
108
+ * The Harness `tsconfig.base.json` maps `@deepseek-ai/*` package names onto
109
+ * `src/` directories; tsx honors those paths, so a CLI launched with
110
+ * `node --import tsx/esm apps/cli/src/bin.ts` imports `@deepseek-ai/dsh-scope`
111
+ * from `src/index.ts` while a profile-installed bundle resolves the compiled
112
+ * `lib/index.js` — two module instances with independent scope keys.
113
+ */
114
+ export function isTsxDevMode() {
115
+ const flags = [...process.execArgv];
116
+ const nodeOptions = process.env.NODE_OPTIONS;
117
+ if (nodeOptions !== undefined)
118
+ flags.push(...nodeOptions.split(/\s+/));
119
+ return flags.some(flag => flag.includes('tsx'));
120
+ }
121
+ /**
122
+ * The activation diagnostic for a dsh-scope module-instance mismatch.
123
+ *
124
+ * `agentPresets.mount` already rejected an unscoped context, so a scope key
125
+ * the harness sees but this bundle does not can only mean the two sides
126
+ * loaded different physical copies of `@deepseek-ai/dsh-scope`.
127
+ */
128
+ export function teamPresetScopeMismatchMessage(tsxDevMode) {
129
+ if (tsxDevMode) {
130
+ return 'selected preset is not team-enabled: the dsh CLI is running from source via tsx (tsconfig paths resolve @deepseek-ai/dsh-scope to src/), so the harness and this bundle load different module instances; start dsh with the compiled CLI instead (pnpm dsh, node apps/cli/lib/bin.js, or npx @deepseek-ai/dsh)';
131
+ }
132
+ return 'selected preset is not team-enabled: this bundle and the harness resolved different physical copies of @deepseek-ai/dsh-scope; run pnpm install in the profile directory so node_modules matches the lockfile, then restart';
133
+ }
82
134
  /**
83
135
  * A DM was durably recorded but its session injection could not run (no live
84
136
  * handle, or the wake itself failed). The recorded DM stays durable; the
@@ -217,7 +269,7 @@ let AgentTeam = (() => {
217
269
  * stale keys.
218
270
  */
219
271
  memberFailures = new Map();
220
- autoCompaction;
272
+ pressurePolicy;
221
273
  notifiedInbox = new Map();
222
274
  attachmentGcTimer;
223
275
  recovery = new RecoveryCoordinator({
@@ -245,38 +297,46 @@ let AgentTeam = (() => {
245
297
  },
246
298
  log: message => { this.ctx.logger.warn(message); },
247
299
  });
300
+ /**
301
+ * Context self-management: the one deep module that turns a Member's
302
+ * successful `context_rollover` tool result into its next private context
303
+ * generation. The ledger owns the binding audit, the Session projection
304
+ * owns intent, and this coordinator owns only reconstructible process
305
+ * state. See docs/architecture.md and docs/team-collaboration.md.
306
+ */
307
+ contextManagement = new ContextManagementCoordinator({
308
+ agentForMember: memberId => this.handles.get(memberId)?.agent,
309
+ memberForAgent: agent => this.memberForAgent(agent),
310
+ projectionForMember: (memberId, sessionId) => {
311
+ const handle = this.handles.get(memberId);
312
+ if (handle === undefined || handle.agent.session.id !== sessionId)
313
+ return undefined;
314
+ return foldContextProjection(handle.agent.session.ownEvents(), handle.agent.session.inheritedEventCount, handle.agent.session.id);
315
+ },
316
+ executeTransition: (memberId, plan) => this.executeMemberTransition(memberId, plan),
317
+ log: message => { this.ctx.logger.warn(`agent-team: ${message}`); },
318
+ });
248
319
  lifecycleTail = Promise.resolve();
249
320
  accepting = true;
250
321
  changeVersion = 0;
251
322
  changeWaiters = new Set();
252
323
  constructor(ctx) {
253
324
  super(ctx, 'agentTeam');
254
- this.autoCompaction = new AutoCompactionCoordinator({
325
+ this.pressurePolicy = new PressurePolicyCoordinator({
255
326
  agentForMember: memberId => this.handles.get(memberId)?.agent,
327
+ memberForAgent: agent => {
328
+ const member = this.memberForAgent(agent);
329
+ return member === undefined ? undefined : { memberId: member.memberId, sessionId: agent.id };
330
+ },
256
331
  compactionForAgent: agent => this.ctx.agentPresets.serviceFor(agent, 'compaction'),
257
- reactivate: memberId => this.reactivateMember(memberId),
332
+ limitsForAgent: agent => this.routeLimitsForAgent(agent),
333
+ activeClaimLabels: memberId => this.activeClaimLabels(memberId),
334
+ runningJobLabels: memberId => this.runningJobLabels(memberId),
258
335
  failed: (memberId, _sessionId, diagnostic) => {
259
336
  this.setMemberFailure(memberId, 'compaction', diagnostic);
260
337
  this.emitAutoCompactionChanged(memberId);
261
338
  },
262
- cleared: memberId => {
263
- if (!this.clearMemberFailure(memberId, 'compaction'))
264
- return;
265
- this.emitAutoCompactionChanged(memberId);
266
- },
267
339
  log: message => { this.ctx.logger.warn(`agent-team: ${message}`); },
268
- // Advisory only: the Agent decides whether anything is worth persisting.
269
- steerPreCompaction: agent => {
270
- const hint = createUserMessage({
271
- content: [{ type: 'text', text: preCompactionNoticeText() }],
272
- source: { kind: 'plugin', plugin: AGENT_TEAM_PLUGIN_ID, form: 'notice', summary: PRE_COMPACTION_NOTICE_SUMMARY },
273
- });
274
- // Persist-your-conclusions outranks a queued progress nudge.
275
- const member = this.memberForAgent(agent);
276
- if (member !== undefined)
277
- this.progressNudge.revokePendingNotice(member.memberId);
278
- agent.steer(hint);
279
- },
280
340
  });
281
341
  }
282
342
  /** Open the durable ledger and restore every enabled Member independently. */
@@ -304,7 +364,12 @@ let AgentTeam = (() => {
304
364
  if (recovered)
305
365
  this.notifiedInbox.delete(member.memberId);
306
366
  this.emitChanged([{ kind: 'workspace', workspaceId: member.workspaceId }]);
307
- this.notifyMember(agent);
367
+ // A rollover/recovery in flight delivers its own sequenced
368
+ // rederived Inbox after the handoff and carried input; a status-driven
369
+ // steer here would claim the handoff turn's next step and leapfrog
370
+ // the carried messages.
371
+ if (!this.contextManagement.isTransitioning(member.memberId))
372
+ this.notifyMember(agent);
308
373
  }
309
374
  // A turn that ends without an error closes any automatic recovery episode.
310
375
  // The idle transition is itself presence-affecting (working → available),
@@ -334,13 +399,21 @@ let AgentTeam = (() => {
334
399
  if (handle === undefined || handle.agent.session.id !== session.id)
335
400
  return;
336
401
  this.progressNudge.onSessionEvent(memberId, session.id, handle.agent, event);
402
+ // A successful assistant response ends any open provider-overflow
403
+ // recovery sequence for this Member.
404
+ if (event.type === 'assistant/message')
405
+ this.pressurePolicy.onAssistantMessage(handle.agent);
406
+ // Context management reacts only after a successful durable tool/result;
407
+ // the projection (not this listener) decides what that means.
408
+ this.contextManagement.onSessionEvent(memberId, handle.agent, event);
337
409
  });
338
410
  const domain = await this.ctx.storageDomain.open(agentTeamDomainSpec);
339
411
  this.ctx.effect(() => async () => {
340
412
  this.accepting = false;
341
413
  this.recovery.dispose();
342
414
  this.progressNudge.dispose();
343
- await this.autoCompaction.dispose();
415
+ this.contextManagement.dispose();
416
+ this.pressurePolicy.dispose();
344
417
  if (this.attachmentGcTimer !== undefined)
345
418
  clearInterval(this.attachmentGcTimer);
346
419
  this.attachmentGcTimer = undefined;
@@ -360,9 +433,20 @@ let AgentTeam = (() => {
360
433
  if (initialization.committed)
361
434
  this.emitCommitted(initialization.value);
362
435
  this.startAttachmentGc(ledger);
436
+ // Legacy-artifact remediation runs before any Member activation: no write
437
+ // lease exists yet, so publishing sibling generations for refused Session
438
+ // logs cannot race a live writer. Remediation failure never blocks
439
+ // startup — the next start retries exactly what the cache does not cover.
440
+ try {
441
+ const remediation = new SessionRemediation(this.ctx, this.ctx.sessionPersistence, await SessionRemediation.open(this.ctx));
442
+ await remediation.remediateEnabledMembers(ledger.listMembers());
443
+ }
444
+ catch (error) {
445
+ this.ctx.logger.warn(`agent-team: legacy Session remediation did not run to completion (it will retry on the next start): ${error instanceof Error ? error.message : String(error)}`);
446
+ }
363
447
  // One metadata listing serves every Member restore; per-member list calls
364
448
  // would repeat the same I/O linearly during startup.
365
- const persistedSessions = new Set((await this.ctx.sessionPersistence.list()).map(header => header.id));
449
+ const persistedSessions = new Set((await this.persistedSessionHeaders()).map(snapshot => snapshot.header.id));
366
450
  for (const member of ledger.listMembers()) {
367
451
  if (member.state === 'enabled')
368
452
  await this.activateMember(member, undefined, persistedSessions);
@@ -370,6 +454,17 @@ let AgentTeam = (() => {
370
454
  await this.memberRuntime.cleanupRemovedMember(member);
371
455
  }
372
456
  }
457
+ /**
458
+ * Whether one Member Session has durable persisted content, decided through
459
+ * {@link SessionPersistence.stat} rather than a bare metadata listing: the
460
+ * backend reports a still-draining session through its pending header, so a
461
+ * resume racing a suspend's fire-and-forget final flush cannot mistake a
462
+ * still-draining persisted Session for an unpersisted one and fork a fresh
463
+ * generation over it.
464
+ */
465
+ async sessionPersisted(sessionId) {
466
+ return (await this.ctx.sessionPersistence.stat(sessionId)) !== undefined;
467
+ }
373
468
  /** Resolve one exact live Agent to its durable Team Member; forks do not inherit identity. */
374
469
  memberForAgent(agent) {
375
470
  for (const [memberId, handle] of this.handles) {
@@ -497,7 +592,7 @@ let AgentTeam = (() => {
497
592
  presetId: request.presetId,
498
593
  ...(request.model === undefined ? {} : { model: Object.freeze({ ...request.model }) }),
499
594
  ...(request.capabilities === undefined ? {} : { capabilities: Object.freeze(deepCopyCapabilities(request.capabilities)) }),
500
- privateMemoryPath: dshHomePath('agent-team', 'members', memberId),
595
+ privateMemoryPath: dshHomePath('agent-team', 'members', memberMemoryDirectoryName(memberId)),
501
596
  state: 'enabled',
502
597
  });
503
598
  const result = await this.requireLedger().addMember({ ...request, actor: agentTeamHumanActor(), member });
@@ -526,7 +621,13 @@ let AgentTeam = (() => {
526
621
  if (result.committed)
527
622
  this.emitCommitted(result.value.receipt);
528
623
  this.clearMemberNotificationState(result.value.member.memberId);
529
- await this.activateMember(result.value.member);
624
+ // The suspended Session's log is durable once its retirement completes,
625
+ // and agents.resume() waits for exactly that retirement before loading.
626
+ // Consulting the persistence tree here instead would race the
627
+ // fire-and-forget retirement on Windows, where the JSONL backend
628
+ // publishes directories through transient staging entries that surface
629
+ // as ENOENT mid-walk — so pass the known session rather than re-listing.
630
+ await this.activateMember(result.value.member, undefined, new Set([result.value.member.sessionId]));
530
631
  return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(result.value.member) });
531
632
  });
532
633
  }
@@ -604,22 +705,7 @@ let AgentTeam = (() => {
604
705
  return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(result.value.member) });
605
706
  }
606
707
  const renewed = result.value.member;
607
- // Drop the old handle's transient state: pending recovery episodes and
608
- // error markers belong to the disposed agent, not to the Member.
609
- this.recovery.stopTracking(request.memberId);
610
- // The fresh Session may re-earn one Claim suggestion per Thread; the
611
- // old Session's one-shot records must not leak into it.
612
- this.progressNudge.stopTracking(request.memberId);
613
- this.memberBySessionId.delete(previousSessionId);
614
- await active.dispose();
615
- this.handles.delete(request.memberId);
616
- this.modelSelections.delete(request.memberId);
617
- this.memberRuntime.forgetMember(request.memberId);
618
- this.clearMemberFailure(request.memberId, 'activation');
619
- this.clearMemberNotificationState(request.memberId);
620
- // The previous log survives on disk; archiving hides it from every
621
- // grouping surface so one Member keeps exactly one visible Session.
622
- await this.ctx.workspaceRegistry.archiveSession(previousSessionId);
708
+ await this.retireMemberGeneration(request.memberId, active, previousSessionId);
623
709
  await this.activateMember(renewed, undefined, undefined, previousSessionId);
624
710
  const reactivated = this.handles.get(request.memberId);
625
711
  if (reactivated === undefined) {
@@ -630,6 +716,126 @@ let AgentTeam = (() => {
630
716
  return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(renewed) });
631
717
  });
632
718
  }
719
+ /**
720
+ * Retire one Member's previous generation after its durable binding moved
721
+ * onto a new Session id: drop the old handle's transient state, dispose the
722
+ * Agent, and archive the old Session log (which stays on disk for history).
723
+ * Shared by the Human clear path and the model-initiated rollover.
724
+ */
725
+ async retireMemberGeneration(memberId, active, previousSessionId) {
726
+ // Drop the old handle's transient state: pending recovery episodes and
727
+ // error markers belong to the disposed agent, not to the Member.
728
+ this.recovery.stopTracking(memberId);
729
+ // The fresh Session may re-earn one Claim suggestion per Thread; the
730
+ // old Session's one-shot records must not leak into it.
731
+ this.progressNudge.stopTracking(memberId);
732
+ // The context admission gate stays armed through disposal: input racing
733
+ // the retire window must still be captured for the new generation, and
734
+ // the coordinator drops its own bookkeeping only after the swap settles.
735
+ this.memberBySessionId.delete(previousSessionId);
736
+ await active.dispose();
737
+ this.handles.delete(memberId);
738
+ this.modelSelections.delete(memberId);
739
+ this.memberRuntime.forgetMember(memberId);
740
+ this.clearMemberFailure(memberId, 'activation');
741
+ this.clearMemberNotificationState(memberId);
742
+ // The previous log survives on disk; archiving hides it from every
743
+ // grouping surface so one Member keeps exactly one visible Session.
744
+ await this.ctx.workspaceRegistry.archiveSession(previousSessionId);
745
+ }
746
+ /**
747
+ * Execute one prepared context rollover at a true idle boundary: commit the
748
+ * idempotent Member-actor operation, retire the previous generation, and
749
+ * activate the fresh Session whose first model-facing context is the
750
+ * Member's own handoff. Later non-Team input captured during the transition
751
+ * is delivered after the handoff; the Team Inbox is rederived from the
752
+ * ledger, never copied.
753
+ */
754
+ async executeMemberTransition(memberId, plan) {
755
+ await this.enqueueLifecycle(async () => {
756
+ this.requireAccepting();
757
+ const stored = this.requireLedger().getMember(memberId);
758
+ if (stored === undefined || stored.state !== 'enabled')
759
+ throw new Error(`Agent Member '${memberId}' cannot roll over: not enabled`);
760
+ if (stored.sessionId !== plan.previousSessionId)
761
+ throw new Error(`Agent Member '${stored.handle}' is no longer bound to the rolled-over Session`);
762
+ const active = this.handles.get(memberId);
763
+ if (active === undefined)
764
+ throw new Error(`Agent Member '${stored.handle}' has no active session to roll over`);
765
+ // A racing turn the admission gate rejects still leaves the Agent
766
+ // momentarily running; wait for its convergence instead of failing the
767
+ // swap — the gate guarantees it spends no model request.
768
+ if (this.runningAgents.has(active.agent.id))
769
+ await active.agent.whenIdle();
770
+ if (this.runningAgents.has(active.agent.id))
771
+ throw new Error(`Agent Member '${stored.handle}' is still running; the rollover must wait for idle`);
772
+ // Checkpoint return: resolve the seed before committing anything. A
773
+ // violation found here fails the whole swap with the old generation
774
+ // intact — never a guessed seed over a wrong prefix.
775
+ const seed = plan.checkpointRef === undefined ? undefined : await this.resolveCheckpointSeed(memberId, active.agent, plan.checkpointRef);
776
+ // Recheck the job guard at the lifecycle commit seam: a job may have
777
+ // started or settled after the tool-time validation.
778
+ const blockingJobs = this.ownedJobsBlockingRollover(active.agent);
779
+ if (blockingJobs.length > 0) {
780
+ throw new Error(`the context rollover is refused: this Member now owns jobs that would not survive the switch (${blockingJobs.join(', ')}); collect or stop them, then retry`);
781
+ }
782
+ const rolled = await this.rolloverSessionForAgent(active.agent, {
783
+ requestId: plan.requestId,
784
+ workspaceId: stored.workspaceId,
785
+ memberId,
786
+ previousSessionId: plan.previousSessionId,
787
+ newSessionId: plan.newSessionId,
788
+ handoffEventSeq: plan.handoffEventSeq,
789
+ trigger: plan.trigger,
790
+ ...(seed === undefined ? {} : { checkpointRef: seed.checkpointRef, sourceSessionId: seed.sourceSessionId, sourceThroughSeq: seed.sourceThroughSeq }),
791
+ });
792
+ // The durable old-log projection is the carried-input truth: after the
793
+ // old Agent retires, fold its final state and take every post-intent
794
+ // non-Team candidate the old generation never answered. The process
795
+ // capture only accelerates; it is unioned by message id, never allowed
796
+ // to override the fold.
797
+ const preRetireCapture = this.contextManagement.drainCapturedInput(memberId);
798
+ await this.retireMemberGeneration(memberId, active, plan.previousSessionId);
799
+ const finalState = foldContextProjection(active.agent.session.ownEvents(), active.agent.session.inheritedEventCount, active.agent.session.id);
800
+ const foldedCarried = carriedInputOf(finalState);
801
+ const carriedById = new Map(plan.carriedInput.map(message => [message.id, message]));
802
+ for (const message of foldedCarried)
803
+ carriedById.set(message.id, message);
804
+ for (const message of preRetireCapture)
805
+ if (!carriedById.has(message.id))
806
+ carriedById.set(message.id, message);
807
+ for (const message of this.contextManagement.drainCapturedInput(memberId))
808
+ if (!carriedById.has(message.id))
809
+ carriedById.set(message.id, message);
810
+ const carriedInput = [...carriedById.values()];
811
+ // A fresh rollover seeds nothing and points the lineage parent at the
812
+ // previous active Session; a checkpoint return seeds the resolved
813
+ // prefix and parents at the seed source Session instead. Activation
814
+ // defers the ordinary Inbox wake so the handoff is guaranteed to be the
815
+ // new generation's first model-facing context.
816
+ const seedEvents = seed === undefined ? undefined : seed.prefix;
817
+ await this.activateMember(rolled.member, undefined, undefined, seed === undefined ? plan.previousSessionId : seed.sourceSessionId, {
818
+ deferNotify: true,
819
+ // The prefix is contiguous from seq 0, so its length is exactly the
820
+ // inherited cut the child folds past.
821
+ ...(seedEvents === undefined ? {} : { seed: seedEvents, inheritedEventCount: SessionLogOffset(seedEvents.length) }),
822
+ });
823
+ const reactivated = this.handles.get(memberId);
824
+ if (reactivated === undefined) {
825
+ throw new Error(`Agent Member '${stored.handle}' failed to activate its next context: ${this.memberFailures.get(memberId)?.activation ?? 'unknown error'}`);
826
+ }
827
+ // The handoff is the first model-facing context of the new generation.
828
+ // It rides the step-priority inbox lane (steer) so a later rederived
829
+ // Inbox notice queues behind it instead of preempting it; carried input
830
+ // follows as its own turn, and the Inbox is rederived from ledger facts.
831
+ reactivated.agent.steer(this.contextManagement.handoffMessageFor(plan));
832
+ for (const message of carriedInput)
833
+ reactivated.agent.followup(message);
834
+ const notifications = this.requireLedger().notificationFacts(memberId, { workspaceId: rolled.member.workspaceId });
835
+ if (notifications.length > 0)
836
+ this.notifyMember(reactivated.agent, carriedInput.length > 0);
837
+ });
838
+ }
633
839
  /** Ledger handle for log lines; falls back to the raw id when unknown. */
634
840
  memberLabel(memberId) {
635
841
  return this.ledger?.getMember(memberId)?.handle ?? memberId;
@@ -943,7 +1149,60 @@ let AgentTeam = (() => {
943
1149
  const result = await this.requireLedger().readThread({ ...request, actor });
944
1150
  if (result.committed)
945
1151
  this.emitCommitted(result.value.receipt);
946
- return result.value;
1152
+ const value = result.value;
1153
+ // Private read-time enrich: an acceptance the reader just acknowledged is
1154
+ // a natural Task boundary, so the Host prices the reader's context once,
1155
+ // after the durable read has committed. Never a ledger fact, never
1156
+ // persisted, and a measurement failure degrades to an explicit
1157
+ // `unavailable` — the committed read is never reversed.
1158
+ const advice = await this.acceptanceContextAdvice(agent, value);
1159
+ return advice === undefined ? value : Object.freeze({ ...value, contextAdvice: advice });
1160
+ }
1161
+ /**
1162
+ * Context advice for one acceptance acknowledged by this read: only when
1163
+ * the read carried an unread accept activity AND the Task is still done.
1164
+ * Repeat reads (nothing unread), history-style reads, and reopened Tasks
1165
+ * carry no advice — the acceptance no longer stands.
1166
+ */
1167
+ async acceptanceContextAdvice(agent, read) {
1168
+ if (read.task === undefined || read.task.resolution !== 'accepted' || read.task.status !== 'done')
1169
+ return undefined;
1170
+ const acknowledgedAccept = read.facts.some(entry => entry.unread && entry.fact.kind === 'activity'
1171
+ && entry.fact.activity.kind === 'accept');
1172
+ if (!acknowledgedAccept)
1173
+ return undefined;
1174
+ return this.contextAdviceFor(agent);
1175
+ }
1176
+ /** Price the reading Member's context against its current route's budgets. */
1177
+ async contextAdviceFor(agent) {
1178
+ try {
1179
+ const limits = await this.routeLimitsForAgent(agent);
1180
+ if (limits === undefined)
1181
+ return this.unavailableAdvice();
1182
+ const taskBoundaryThreshold = Math.min(ACCEPT_TASK_BOUNDARY_THRESHOLD, limits.handoffAt);
1183
+ if (limits.usageTokens >= limits.handoffAt) {
1184
+ return Object.freeze({ usageTokens: limits.usageTokens, taskBoundaryThreshold, handoffAt: limits.handoffAt, hardLimit: limits.hardLimit,
1185
+ action: 'handoff-now',
1186
+ guidance: 'You are at or above the handoff budget. Finish the current atomic action and unsettled evidence, then call context_rollover with a fresh handoff now.' });
1187
+ }
1188
+ if (limits.usageTokens >= taskBoundaryThreshold) {
1189
+ return Object.freeze({ usageTokens: limits.usageTokens, taskBoundaryThreshold, handoffAt: limits.handoffAt, hardLimit: limits.hardLimit,
1190
+ action: 'rollover',
1191
+ guidance: 'Finish the acceptance closeout, persist only durable reusable conclusions, collect or stop jobs, then call context_rollover with a fresh handoff covering every other active Claim. Do not return to an old checkpoint solely because this Task was accepted.' });
1192
+ }
1193
+ return Object.freeze({ usageTokens: limits.usageTokens, taskBoundaryThreshold, handoffAt: limits.handoffAt, hardLimit: limits.hardLimit,
1194
+ action: 'keep',
1195
+ guidance: 'Keep the current context for possible acceptance follow-up. This acceptance is already a timeline boundary; do not create a redundant checkpoint. Record a checkpoint only before the next noisy or risky phase.' });
1196
+ }
1197
+ catch {
1198
+ // Any measurement failure degrades explicitly; the read stays durable.
1199
+ return this.unavailableAdvice();
1200
+ }
1201
+ }
1202
+ unavailableAdvice() {
1203
+ return Object.freeze({ usageTokens: undefined, taskBoundaryThreshold: undefined, handoffAt: undefined, hardLimit: undefined,
1204
+ action: 'unavailable',
1205
+ guidance: 'Context usage could not be measured for this acceptance; manage context by your existing pressure policy.' });
947
1206
  }
948
1207
  /**
949
1208
  * Agent-only direct message: append the audit-only dm-sent operation, then
@@ -965,7 +1224,7 @@ let AgentTeam = (() => {
965
1224
  }
966
1225
  try {
967
1226
  const message = createUserMessage({
968
- content: [{ type: 'text', text: this.dmRelayText(agent, recipient, request.body.trim(), result.value.receipt.operationId) }],
1227
+ content: [{ type: 'text', text: this.dmRelayText(agent, recipient, request.body.trim(), result.value.receipt.occurredAt, result.value.receipt.operationId) }],
969
1228
  source: { kind: 'plugin', plugin: AGENT_TEAM_PLUGIN_ID, form: 'relay' },
970
1229
  });
971
1230
  // An idle recipient gets one ordinary turn; a busy one is steered into
@@ -981,10 +1240,10 @@ let AgentTeam = (() => {
981
1240
  return result.value;
982
1241
  }
983
1242
  /** Relay body: the DM itself plus one bounded line of adjacent context. */
984
- dmRelayText(senderAgent, recipient, body, excluding) {
1243
+ dmRelayText(senderAgent, recipient, body, occurredAt, excluding) {
985
1244
  const sender = this.memberForAgent(senderAgent);
986
1245
  const prior = this.requireLedger().dmHistoryBetween(senderAgent.id, recipient.memberId, excluding);
987
- const header = `Direct message from @${sender?.handle ?? 'a Team Member'}:`;
1246
+ const header = `Direct message from @${sender?.handle ?? 'a Team Member'} at ${formatTeamTimestamp(occurredAt)}:`;
988
1247
  const context = prior === undefined ? '' : `\n\n[most recent prior DM between you: ${prior}]`;
989
1248
  return `${header}\n\n${body}${context}`;
990
1249
  }
@@ -1006,6 +1265,715 @@ let AgentTeam = (() => {
1006
1265
  validateLedger() {
1007
1266
  this.requireLedger().validate();
1008
1267
  }
1268
+ /**
1269
+ * Effective context-pressure budget for one Member's current route:
1270
+ * `hardLimit = min(256K, routeWindow - outputReserve)` and
1271
+ * `handoffAt = min(200K, hardLimit - handoffReserve)`.
1272
+ */
1273
+ contextLimits(routeWindow) {
1274
+ const hardLimit = Math.min(CONTEXT_HARD_LIMIT_CAP, Math.max(0, routeWindow - CONTEXT_SAFE_OUTPUT_RESERVE));
1275
+ const handoffAt = Math.min(CONTEXT_HANDOFF_AT_CAP, Math.max(0, hardLimit - CONTEXT_HANDOFF_RESERVE));
1276
+ return { hardLimit, handoffAt };
1277
+ }
1278
+ /**
1279
+ * Budget + measurement for one Member's CURRENT routed selection — the
1280
+ * member-pinned model or the default selection, resolved through the LLM
1281
+ * service so a route change is honored at the next pre-step. The last
1282
+ * persisted `request/context` event wins when it matches the current
1283
+ * selection (no directory round-trip for an unchanged route). A selection
1284
+ * whose capacity cannot be resolved returns `undefined` and the pressure
1285
+ * policy fails closed instead of assuming an unbounded route.
1286
+ */
1287
+ async routeLimitsForAgent(agent) {
1288
+ const member = this.memberForAgent(agent);
1289
+ if (member === undefined)
1290
+ return undefined;
1291
+ // The pressure budget must match the route the CURRENT step actually
1292
+ // uses: the live selection ref's `assembled` capture when the step has
1293
+ // entered prompt assembly, its `current` selection otherwise. Re-deriving
1294
+ // from the ledger or the Host default would race a concurrent default
1295
+ // change or live model edit and split the budget from the real route.
1296
+ const ref = this.modelSelections.get(member.memberId);
1297
+ const selection = ref?.assembled ?? ref?.current ?? member.model ?? this.ctx.agentDefaultModel.currentSelection();
1298
+ const meter = agent.ctx.get('tokenMeter');
1299
+ const usageTokens = meter?.measure(agent.session)?.totalTokens;
1300
+ if (usageTokens === undefined)
1301
+ return undefined;
1302
+ const persisted = agent.session.requestContext();
1303
+ let contextWindow;
1304
+ if (persisted?.provider === selection.provider && persisted.model === selection.model) {
1305
+ contextWindow = persisted.contextWindow;
1306
+ }
1307
+ if (contextWindow === undefined) {
1308
+ contextWindow = await this.resolvedContextWindow(selection);
1309
+ }
1310
+ if (contextWindow === undefined)
1311
+ return undefined;
1312
+ const { hardLimit, handoffAt } = this.contextLimits(contextWindow);
1313
+ return { usageTokens, hardLimit, handoffAt };
1314
+ }
1315
+ /** Cache of resolved context windows per provider/model; unknown stays unknown. */
1316
+ routeWindows = new Map();
1317
+ /**
1318
+ * Resolve one selection's provider-owned context capacity, cached per
1319
+ * route. The LLM service is resolved lazily (not a hard inject): the Host
1320
+ * mounts it in every production shape, while ledger-level fixtures provide
1321
+ * only the services the Team service itself owns. A missing or throwing
1322
+ * resolution is an unknown route — the pressure policy fails closed on it.
1323
+ */
1324
+ async resolvedContextWindow(selection) {
1325
+ const key = `${selection.provider}::${selection.model}`;
1326
+ if (this.routeWindows.has(key))
1327
+ return this.routeWindows.get(key);
1328
+ let contextWindow;
1329
+ try {
1330
+ const llm = this.ctx.get('llm');
1331
+ const info = llm === undefined ? undefined : await llm.resolveModelInfo(selection.provider, selection.model);
1332
+ contextWindow = info?.context?.contextWindow;
1333
+ }
1334
+ catch {
1335
+ contextWindow = undefined;
1336
+ }
1337
+ this.routeWindows.set(key, contextWindow);
1338
+ return contextWindow;
1339
+ }
1340
+ /**
1341
+ * Resolve one checkpoint ref to its exact seed prefix before any rollover
1342
+ * commit. The walk covers the current generation (own events) and archived
1343
+ * ancestors through `sessionPersistence`; the same projection definition
1344
+ * folds every source. Guards fail closed: unresolved, foreign-lineage,
1345
+ * open-turn, or nonshrinking targets reject without any lifecycle effect.
1346
+ */
1347
+ async resolveCheckpointSeed(memberId, agent, checkpointRef) {
1348
+ let sessionId = agent.session.id;
1349
+ let live = true;
1350
+ let guard = 0;
1351
+ while (sessionId !== undefined && guard++ < MAX_TIMELINE_ANCESTORS) {
1352
+ let events;
1353
+ let inheritedEventCount;
1354
+ let parentSession;
1355
+ // Capture this iteration's source identity BEFORE the branch advances
1356
+ // the lineage flag: the measurement call below keys on it.
1357
+ const sourceIsCurrent = live;
1358
+ if (live) {
1359
+ events = agent.session.snapshotEvents();
1360
+ inheritedEventCount = agent.session.inheritedEventCount;
1361
+ parentSession = agent.session.header.parentSession;
1362
+ live = false;
1363
+ }
1364
+ else {
1365
+ try {
1366
+ const handle = await this.ctx.sessionPersistence.open(sessionId, 'read');
1367
+ try {
1368
+ events = (await handle.read()).events;
1369
+ inheritedEventCount = handle.inheritedEventCount;
1370
+ parentSession = handle.header.parentSession;
1371
+ }
1372
+ finally {
1373
+ await handle.close();
1374
+ }
1375
+ }
1376
+ catch (error) {
1377
+ throw new Error(`checkpoint '${checkpointRef}' could not be resolved: its source Session is unreadable (${error instanceof Error ? error.message : String(error)})`);
1378
+ }
1379
+ }
1380
+ // Fold the source with its inherited cut respected: inherited events
1381
+ // are resolved history in that source, never fresh intent; checkpoints
1382
+ // recorded in this source's own span are the selectable targets.
1383
+ const state = foldContextProjection(events, inheritedEventCount, sessionId);
1384
+ // A Team-boundary default checkpoint: the boundary's completed-turn
1385
+ // anchor is the seed cut, and it is selectable exactly when one
1386
+ // Thread's facts entered the context through it — the same proof the
1387
+ // timeline requires, revalidated here because the model may cite a
1388
+ // boundary the timeline never surfaced.
1389
+ const boundary = checkpointRef.startsWith('team-boundary-')
1390
+ ? state.boundaries.find(entry => entry.key === checkpointRef)
1391
+ : undefined;
1392
+ const entry = checkpointByRef(state, checkpointRef);
1393
+ const anchorTurnEndSeq = boundary !== undefined ? boundary.turnEndSeq : entry?.turnEndSeq;
1394
+ if (anchorTurnEndSeq !== undefined && anchorTurnEndSeq !== -1) {
1395
+ if (boundary !== undefined && boundary.source !== 'team-boundary') {
1396
+ throw new Error(`checkpoint '${checkpointRef}' is not a restorable boundary`);
1397
+ }
1398
+ // The seed is the exact contiguous prefix through the anchor's
1399
+ // completed turn end. Balanced by construction — the turn ended.
1400
+ const throughSeq = anchorTurnEndSeq + 1;
1401
+ const prefix = events.slice(0, throughSeq);
1402
+ if (boundary !== undefined) {
1403
+ const threads = this.threadsEnteringContext(events, anchorTurnEndSeq);
1404
+ if (threads.length !== 1) {
1405
+ throw new Error(threads.length === 0
1406
+ ? `boundary '${checkpointRef}' has no single attributable Thread; write a fresh handoff instead`
1407
+ : `boundary '${checkpointRef}' spans multiple Threads; write a fresh handoff instead`);
1408
+ }
1409
+ }
1410
+ // Nonshrinking guard, priced by the SAME source-replayed measurement
1411
+ // the timeline shows: the seed's retained cost is the SOURCE's own
1412
+ // token count scaled by the anchor share — a small current generation
1413
+ // never disguises a large ancestor seed. An unmeasurable source fails
1414
+ // closed: pricing the unknown as zero would wave an oversized seed
1415
+ // through.
1416
+ const limits = await this.routeLimitsForAgent(agent);
1417
+ const handoffAt = limits?.handoffAt ?? CONTEXT_HANDOFF_AT_CAP;
1418
+ const sourceUsage = await this.sourceUsageTokens(sessionId, sourceIsCurrent, agent);
1419
+ if (sourceUsage === undefined) {
1420
+ throw new Error(`checkpoint '${checkpointRef}' could not be priced: its source Session's context cost cannot be measured; write a fresh handoff instead`);
1421
+ }
1422
+ const retained = this.retainedEstimate(sourceUsage, events.length, anchorTurnEndSeq);
1423
+ if (prefix.length >= events.length) {
1424
+ throw new Error('checkpoint return does not shrink the working set; use a fresh handoff instead');
1425
+ }
1426
+ if (retained >= handoffAt) {
1427
+ throw new Error('checkpoint return would retain a context at or above the handoff budget; use a fresh handoff instead');
1428
+ }
1429
+ // Single-Thread coverage guard: with more than one active Claim the
1430
+ // Host cannot prove a rewind stays inside one Thread's context.
1431
+ if (this.requireLedger().activeClaimCountForMember(memberId) > 1) {
1432
+ throw new Error('multiple active Claims: write a fresh handoff covering all of them instead of returning to a checkpoint');
1433
+ }
1434
+ return { checkpointRef, sourceSessionId: sessionId, sourceThroughSeq: SessionLogOffset(throughSeq), prefix };
1435
+ }
1436
+ sessionId = parentSession;
1437
+ }
1438
+ throw new Error(`checkpoint '${checkpointRef}' does not resolve in this Member's lineage`);
1439
+ }
1440
+ /**
1441
+ * Rebuild the handoff for a Member whose rollover committed but whose new
1442
+ * Session activated without the handoff delivery (a crash between the
1443
+ * ledger commit and the swap's delivery step). The previous Session —
1444
+ * recorded in the operation, mirrored by the lineage parent, and available
1445
+ * from the ledger even when the new Session's own header never carried it —
1446
+ * holds the durable intent; fold it cold and deliver the same handoff
1447
+ * envelope first. Idempotent: once any handoff exists in the new Session's
1448
+ * own log this never runs.
1449
+ */
1450
+ async reconstructMissingHandoff(member, agent) {
1451
+ const previousSessionId = agent.session.header.parentSession
1452
+ ?? this.requireLedger().previousSessionForMember(member.memberId);
1453
+ if (previousSessionId === undefined)
1454
+ return;
1455
+ let inspection;
1456
+ try {
1457
+ const handle = await this.ctx.sessionPersistence.open(previousSessionId, 'read');
1458
+ try {
1459
+ inspection = { events: (await handle.read()).events, inheritedEventCount: handle.inheritedEventCount };
1460
+ }
1461
+ finally {
1462
+ await handle.close();
1463
+ }
1464
+ }
1465
+ catch (error) {
1466
+ this.ctx.logger.warn(`agent-team: rollover handoff reconstruction could not read the previous Session '${previousSessionId}': ${error instanceof Error ? error.message : String(error)}`);
1467
+ return;
1468
+ }
1469
+ const state = foldContextProjection(inspection.events, inspection.inheritedEventCount, previousSessionId);
1470
+ if (state.pending === null)
1471
+ return;
1472
+ const pending = state.pending;
1473
+ const message = createHandoffMessage({
1474
+ handoff: pending.handoff,
1475
+ previousSessionId,
1476
+ newSessionId: agent.session.id,
1477
+ trigger: 'model',
1478
+ handoffEventSeq: pending.resultSeq,
1479
+ ...(pending.checkpointRef === undefined ? {} : { checkpointRef: pending.checkpointRef }),
1480
+ ...(pending.relatedFiles.length === 0 ? {} : { relatedFiles: pending.relatedFiles }),
1481
+ });
1482
+ agent.steer(message);
1483
+ this.ctx.logger.info(`agent-team: reconstructed the rollover handoff for member '${this.memberLabel(member.memberId)}' from the previous Session '${previousSessionId}'`);
1484
+ }
1485
+ /**
1486
+ * Resolve the exact recorded seed prefix of one committed checkpoint
1487
+ * return for crash recovery: cold-read the recorded source Session, take
1488
+ * the contiguous prefix of the recorded exclusive length, and verify the
1489
+ * source's own fold still resolves the recorded anchor there — an explicit
1490
+ * checkpoint through `checkpointByRef`, or a default Team boundary whose
1491
+ * recorded ref appears among the source's own boundary keys.
1492
+ * Fail-closed by contract: an unreadable source or an unprovable anchor
1493
+ * REJECTS the activation — a committed checkpoint return may never
1494
+ * downgrade to a blank child.
1495
+ */
1496
+ async recordedCheckpointPrefix(seed) {
1497
+ let inspection;
1498
+ try {
1499
+ const handle = await this.ctx.sessionPersistence.open(seed.sourceSessionId, 'read');
1500
+ try {
1501
+ inspection = { events: (await handle.read()).events, inheritedEventCount: handle.inheritedEventCount };
1502
+ }
1503
+ finally {
1504
+ await handle.close();
1505
+ }
1506
+ }
1507
+ catch (error) {
1508
+ throw new Error(`the recorded checkpoint-return seed source Session '${seed.sourceSessionId}' is unreadable: ${error instanceof Error ? error.message : String(error)}`);
1509
+ }
1510
+ const through = Number(seed.sourceThroughSeq);
1511
+ if (!Number.isSafeInteger(through) || through < 0 || through > inspection.events.length) {
1512
+ throw new Error(`the recorded checkpoint-return seed cut ${through} is not a valid prefix of Session '${seed.sourceSessionId}'`);
1513
+ }
1514
+ const state = foldContextProjection(inspection.events, inspection.inheritedEventCount, seed.sourceSessionId);
1515
+ const anchorProven = checkpointByRef(state, seed.checkpointRef) !== undefined
1516
+ || state.boundaries.some(boundary => boundary.key === seed.checkpointRef);
1517
+ if (!anchorProven) {
1518
+ throw new Error(`the recorded checkpoint-return anchor '${seed.checkpointRef}' no longer resolves in Session '${seed.sourceSessionId}'`);
1519
+ }
1520
+ return { prefix: inspection.events.slice(0, through) };
1521
+ }
1522
+ /**
1523
+ * Redeliver the old generation's unconsumed post-intent input to a
1524
+ * generation whose durable rollover committed but whose delivery did not
1525
+ * finish. Bound to the ledger's recorded transition target — the CURRENT
1526
+ * Session being that target — never to whether the handoff itself landed,
1527
+ * so a crash after the handoff but before the carried enqueue still
1528
+ * replays. The fold is the same durable truth the live transition uses
1529
+ * (unconsumed, non-Team, order preserved). Idempotency keys on what is
1530
+ * CURRENTLY present: delivered `user/message` ids plus the live inbox's
1531
+ * pending next-step/next-turn ids — a historical insert that was already
1532
+ * claimed (and removed) but never surfaced is NOT known and must replay.
1533
+ * Fail-closed for genuinely unreadable previous Sessions (missing/IO) so
1534
+ * the Member's input is never dropped silently, with three bounded
1535
+ * exceptions: a generation that already started its own turns needs no
1536
+ * replay (its carried input was delivered or superseded while it ran), a
1537
+ * log-corruption class error skips with a warning (the Host repairs torn
1538
+ * tails; a retired generation's corrupt log must not permanently block the
1539
+ * Member's activation), and a deterministic released-format refusal skips
1540
+ * with a warning (the candidate's own migration audit refuses that
1541
+ * artifact, so no retry can ever read it).
1542
+ */
1543
+ async replayCarriedInput(member, agent, generationStarted) {
1544
+ const transition = this.requireLedger().lastTransitionForMember(member.memberId);
1545
+ if (transition === undefined || transition.targetSessionId !== agent.session.id)
1546
+ return 0;
1547
+ // A generation that already started its own turns needs no previous-Session
1548
+ // replay: its carried input was delivered with the handoff (or superseded)
1549
+ // before any own turn could run. Skipping the inspect also keeps a later
1550
+ // corruption or loss of the retired Session from re-blocking this Member
1551
+ // on every restart of the current generation.
1552
+ if (generationStarted) {
1553
+ this.ctx.logger.info(`agent-team: skipping carried-input replay for member '${this.memberLabel(member.memberId)}': the current Session '${agent.session.id}' already started a generation`);
1554
+ return 0;
1555
+ }
1556
+ let inspectionEvents;
1557
+ let inspectionInherited;
1558
+ try {
1559
+ const handle = await this.ctx.sessionPersistence.open(transition.previousSessionId, 'read');
1560
+ try {
1561
+ inspectionEvents = (await handle.read()).events;
1562
+ inspectionInherited = handle.inheritedEventCount;
1563
+ }
1564
+ finally {
1565
+ await handle.close();
1566
+ }
1567
+ }
1568
+ catch (error) {
1569
+ const detail = error instanceof Error ? error.message : String(error);
1570
+ // Log-corruption class: bounded fail-open. The current Session's own
1571
+ // fold is intact and the Host repairs the retired log's torn tail; a
1572
+ // corrupted retired generation must not permanently block activation.
1573
+ if (/corrupt session log/.test(detail)) {
1574
+ this.ctx.logger.warn(`agent-team: previous Session '${transition.previousSessionId}' holding carried input for member '${this.memberLabel(member.memberId)}' is corrupt: ${detail}; skipping the replay`);
1575
+ return 0;
1576
+ }
1577
+ // Deterministic released-format refusal: the retired artifact is refused
1578
+ // by the candidate's own migration audit, which no retry can change.
1579
+ // Failing activation here would turn a data problem in a Session the
1580
+ // Member no longer runs in into permanent unavailability — the exact
1581
+ // failure class this hardening removes. Genuine IO and unknown failures
1582
+ // stay fail-closed so carried input is never dropped silently.
1583
+ if (error instanceof SessionFormatUnsupportedError) {
1584
+ this.ctx.logger.warn(`agent-team: previous Session '${transition.previousSessionId}' holding carried input for member '${this.memberLabel(member.memberId)}' is refused by the session-format migration: ${detail}; skipping the replay`);
1585
+ return 0;
1586
+ }
1587
+ throw new Error(`the previous Session '${transition.previousSessionId}' holding the Member's carried input is unreadable: ${detail}`);
1588
+ }
1589
+ const state = foldContextProjection(inspectionEvents, inspectionInherited, transition.previousSessionId);
1590
+ const carried = carriedInputOf(state);
1591
+ if (carried.length === 0)
1592
+ return 0;
1593
+ const known = new Set();
1594
+ for (const event of agent.session.ownEvents()) {
1595
+ if (event.type !== 'user/message')
1596
+ continue;
1597
+ const id = event.data.id;
1598
+ if (id !== undefined)
1599
+ known.add(id);
1600
+ }
1601
+ for (const pending of [...agent.inbox.nextStep, ...agent.inbox.nextTurn]) {
1602
+ known.add(pending.id);
1603
+ }
1604
+ let redelivered = 0;
1605
+ for (const message of carried) {
1606
+ if (known.has(message.id))
1607
+ continue;
1608
+ agent.followup(message);
1609
+ redelivered += 1;
1610
+ }
1611
+ if (redelivered > 0)
1612
+ this.ctx.logger.info(`agent-team: redelivered ${redelivered} carried input message(s) for member '${this.memberLabel(member.memberId)}' after the rollover crash recovery`);
1613
+ return redelivered;
1614
+ }
1615
+ /**
1616
+ * Agent-only checkpoint request validation: the tool calls this inside its
1617
+ * own running turn. Like `context_rollover`, the tool performs no side effect —
1618
+ * the durable checkpoint is the successful `tool/call`+`tool/result` pair
1619
+ * the Session projection folds; the ref returned here is deterministic
1620
+ * from this Member Session's identity plus the tool call id, so the model
1621
+ * can cite it before the result exists and a repeated provider call id in
1622
+ * another generation never collides with this one.
1623
+ */
1624
+ recordCheckpointForAgent(agent, request) {
1625
+ const member = this.memberForAgent(agent);
1626
+ if (member === undefined || member.state !== 'enabled')
1627
+ throw new Error('context_checkpoint requires an active Team Member');
1628
+ if (this.runningAgents.has(agent.id) !== true)
1629
+ throw new Error('context_checkpoint must run inside this Member\'s own running turn');
1630
+ const name = request.name.trim();
1631
+ if (name === '')
1632
+ throw new Error('context_checkpoint requires a non-empty name');
1633
+ if (name.length > MAX_CHECKPOINT_NAME_CHARS)
1634
+ throw new Error(`context_checkpoint name exceeds ${MAX_CHECKPOINT_NAME_CHARS} characters`);
1635
+ return { checkpointRef: checkpointRefFor(agent.session.id, request.callId), name };
1636
+ }
1637
+ /**
1638
+ * Agent-only bounded structural timeline: resolved checkpoints plus Team
1639
+ * delivery, handoff, and compaction boundaries across the current
1640
+ * generation and its archived ancestor lineage. Structural only — no
1641
+ * transcript content. The meter prices retained/discarded tokens; entries
1642
+ * the Host cannot prove restorable carry a rejection reason instead of
1643
+ * silently disappearing.
1644
+ */
1645
+ async contextTimelineForAgent(agent, request) {
1646
+ const member = this.memberForAgent(agent);
1647
+ if (member === undefined || member.state !== 'enabled')
1648
+ throw new Error('context_timeline requires an active Team Member');
1649
+ const limit = request.limit === undefined ? DEFAULT_TIMELINE_LIMIT : Math.trunc(request.limit);
1650
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_TIMELINE_LIMIT)
1651
+ throw new Error(`context_timeline limit must be between 1 and ${MAX_TIMELINE_LIMIT}`);
1652
+ const meter = agent.ctx.get('tokenMeter');
1653
+ const measurement = meter?.measure(agent.session);
1654
+ const usageTokens = measurement?.totalTokens ?? 0;
1655
+ const limits = await this.routeLimitsForAgent(agent);
1656
+ const hardLimit = limits?.hardLimit ?? CONTEXT_HARD_LIMIT_CAP;
1657
+ const handoffAt = limits?.handoffAt ?? CONTEXT_HANDOFF_AT_CAP;
1658
+ // Fold the current generation, then walk archived ancestors through their
1659
+ // persisted logs; the same projection definition folds every source.
1660
+ const items = [];
1661
+ const seen = new Set();
1662
+ let sessionId = member.sessionId;
1663
+ let live = true;
1664
+ let guard = 0;
1665
+ while (sessionId !== undefined && guard++ < MAX_TIMELINE_ANCESTORS) {
1666
+ let state;
1667
+ let sourceSessionId = sessionId;
1668
+ let sourceEvents = [];
1669
+ // Capture this iteration's source identity BEFORE the branch advances
1670
+ // the lineage flag: measurement and discard pricing both key on it.
1671
+ const sourceIsCurrent = live;
1672
+ if (live) {
1673
+ state = foldContextProjection(agent.session.ownEvents(), agent.session.inheritedEventCount, agent.session.id);
1674
+ sourceEvents = agent.session.snapshotEvents();
1675
+ sessionId = agent.session.header.parentSession;
1676
+ live = false;
1677
+ }
1678
+ else {
1679
+ try {
1680
+ const handle = await this.ctx.sessionPersistence.open(sessionId, 'read');
1681
+ try {
1682
+ const { events } = await handle.read();
1683
+ state = foldContextProjection(events, handle.inheritedEventCount, sessionId);
1684
+ sourceEvents = events;
1685
+ sourceSessionId = sessionId;
1686
+ sessionId = handle.header.parentSession;
1687
+ }
1688
+ finally {
1689
+ await handle.close();
1690
+ }
1691
+ }
1692
+ catch {
1693
+ // An unreadable ancestor ends the lineage walk here.
1694
+ sessionId = undefined;
1695
+ }
1696
+ }
1697
+ if (state === undefined)
1698
+ break;
1699
+ // Price every candidate of this source against the SOURCE's own
1700
+ // replayed measurement — a small current generation never shrinks a
1701
+ // large ancestor's real seed cost. An unmeasurable source (no meter,
1702
+ // unreadable ancestor) prices as UNKNOWN, never as zero: the timeline
1703
+ // marks its candidates unprovable and the return guard rejects them.
1704
+ const sourceUsage = await this.sourceUsageTokens(sourceSessionId, sourceIsCurrent, agent);
1705
+ for (const candidate of timelineCandidates(state, limit)) {
1706
+ if (seen.has(candidate.ref))
1707
+ continue;
1708
+ seen.add(candidate.ref);
1709
+ items.push(this.timelineItemFor(candidate, sourceUsage, usageTokens, hardLimit, handoffAt, sourceSessionId, sourceIsCurrent, sourceEvents));
1710
+ if (items.length >= limit)
1711
+ break;
1712
+ }
1713
+ if (items.length >= limit)
1714
+ break;
1715
+ }
1716
+ return { usageTokens, hardLimit, handoffAt, items };
1717
+ }
1718
+ /**
1719
+ * Replayed measurement of one lineage source: the live current Session
1720
+ * measures directly; an archived ancestor measures through a borrowed
1721
+ * prepared Session, so a seed's retained cost is priced in the SOURCE's
1722
+ * own tokens — never the current generation's. Returns undefined when no
1723
+ * meter is available or the source cannot be borrowed; callers fail
1724
+ * closed on the unknown.
1725
+ */
1726
+ async sourceUsageTokens(sessionId, live, agent) {
1727
+ const meter = agent.ctx.get('tokenMeter');
1728
+ if (meter === undefined)
1729
+ return undefined;
1730
+ if (live)
1731
+ return meter.measure(agent.session)?.totalTokens;
1732
+ try {
1733
+ // 0.1.5 removed borrowSession: rebuild a detached Session from the
1734
+ // stored log so the seed's retained cost is priced by the SOURCE's own
1735
+ // replay, never the current generation's.
1736
+ const handle = await this.ctx.sessionPersistence.open(sessionId, 'read');
1737
+ try {
1738
+ const { events } = await handle.read();
1739
+ const session = Session.create(sessionId, events, handle.header, handle.inheritedEventCount);
1740
+ return meter.measure(session)?.totalTokens;
1741
+ }
1742
+ finally {
1743
+ await handle.close();
1744
+ }
1745
+ }
1746
+ catch {
1747
+ return undefined;
1748
+ }
1749
+ }
1750
+ /**
1751
+ * Monotonic anchor-share estimate of a seed's retained cost, priced in the
1752
+ * SOURCE Session's own measurement: the fraction of the source log the
1753
+ * seed prefix covers, scaled to the source's replayed token count. The
1754
+ * anchor position is exact and the share grows monotonically toward the
1755
+ * source's head (100%). A large ancestor's anchor therefore prices at the
1756
+ * ancestor's real size even inside a small current generation — the
1757
+ * timeline display and the return guard share this one estimate.
1758
+ */
1759
+ retainedEstimate(sourceUsageTokens, sourceLength, anchorTurnEndSeq) {
1760
+ if (sourceLength <= 0)
1761
+ return sourceUsageTokens;
1762
+ const share = Math.min(1, Math.max(0, (anchorTurnEndSeq + 1) / sourceLength));
1763
+ return Math.round(sourceUsageTokens * share);
1764
+ }
1765
+ /**
1766
+ * Threads whose facts entered this Session's model context by the given
1767
+ * seq: delivered Team notices (their bodies quote `Thread: <ref>`
1768
+ * structurally) and successful Team-claim mutations (their task overlays
1769
+ * resolve to Threads through the ledger). Never from unread ledger
1770
+ * activity — a Thread the Member never saw did not enter its context.
1771
+ * Order-stable, deduplicated.
1772
+ */
1773
+ threadsEnteringContext(events, throughSeq) {
1774
+ const refs = [];
1775
+ const openAttributions = new Map();
1776
+ const push = (ref) => {
1777
+ if (ref !== undefined && !refs.includes(ref))
1778
+ refs.push(ref);
1779
+ };
1780
+ for (const event of events) {
1781
+ if (event.seq > throughSeq)
1782
+ break;
1783
+ if (event.type === 'tool/call' && (event.data.name === 'team_claim' || event.data.name === 'team_message')) {
1784
+ openAttributions.set(event.data.callId, { name: event.data.name, arguments: event.data.arguments });
1785
+ }
1786
+ else if (event.type === 'tool/result') {
1787
+ const block = event.data.message.content?.[0];
1788
+ if (block !== undefined && block.toolCallId !== undefined) {
1789
+ const recorded = openAttributions.get(block.toolCallId);
1790
+ if (recorded !== undefined && block.isError !== true) {
1791
+ openAttributions.delete(block.toolCallId);
1792
+ try {
1793
+ const args = JSON.parse(recorded.arguments);
1794
+ // A claim mutation resolves its Task overlay through the ledger;
1795
+ // a team_message committed reply names its Thread in its call
1796
+ // arguments; a committed start's Thread is born in the result
1797
+ // and is read from the durable presentation meta.
1798
+ if (recorded.name === 'team_claim' && typeof args.taskRef === 'string' && args.taskRef !== '') {
1799
+ push(this.requireLedger().threadForTask(args.taskRef));
1800
+ }
1801
+ else if (recorded.name === 'team_message') {
1802
+ const meta = event.data.meta;
1803
+ if (meta !== undefined && typeof meta === 'object' && meta.kind === 'committed') {
1804
+ const metaThreadRef = meta.threadRef;
1805
+ if (typeof metaThreadRef === 'string' && metaThreadRef !== '')
1806
+ push(metaThreadRef);
1807
+ }
1808
+ else if (typeof args.threadRef === 'string' && args.threadRef !== '') {
1809
+ push(args.threadRef);
1810
+ }
1811
+ }
1812
+ }
1813
+ catch {
1814
+ // Malformed call arguments contribute no attribution.
1815
+ }
1816
+ }
1817
+ }
1818
+ }
1819
+ else if (event.type === 'user/message') {
1820
+ const source = event.data.source;
1821
+ if (source?.kind !== 'plugin')
1822
+ continue;
1823
+ // Reminder notices never enter attribution — a progress nudge or a
1824
+ // recovery instruction is not a Team fact.
1825
+ if (source.form === 'notice' && source.summary !== undefined && isReminderNoticeSummary(source.summary))
1826
+ continue;
1827
+ const text = event.data.content.filter(block => block.type === 'text').map(block => block.text ?? '').join('\n');
1828
+ for (const match of text.matchAll(/Thread: (thread:[0-9a-f-]{6,})/g)) {
1829
+ push(match[1]);
1830
+ }
1831
+ }
1832
+ }
1833
+ return refs;
1834
+ }
1835
+ /**
1836
+ * Price and annotate one timeline candidate without mutating anything.
1837
+ * Retained prices in the SOURCE Session's own replayed measurement (the
1838
+ * monotonic anchor-share of the source log); discarded is what a return
1839
+ * replaces — for a current-generation anchor, the measured usage beyond
1840
+ * the anchor; for an ancestor anchor, the current generation's whole
1841
+ * usage (an approximation: the ancestor's own suffix is not part of this
1842
+ * generation). A small current child therefore discards little but may
1843
+ * still retain a large ancestor seed, and both numbers say so honestly.
1844
+ */
1845
+ timelineItemFor(candidate, sourceUsage, currentUsage, _hardLimit, handoffAt, sourceSessionId, sourceIsCurrent, sourceEvents) {
1846
+ const retainedTokens = candidate.source === 'head'
1847
+ ? currentUsage
1848
+ : this.retainedEstimate(sourceUsage ?? 0, sourceEvents.length, candidate.turnEndSeq);
1849
+ const discardedTokens = candidate.source === 'head'
1850
+ ? 0
1851
+ : sourceIsCurrent
1852
+ ? Math.max(0, currentUsage - retainedTokens)
1853
+ : currentUsage;
1854
+ const affectedThreads = candidate.turnEndSeq === -1 ? [] : this.threadsEnteringContext(sourceEvents, candidate.turnEndSeq);
1855
+ let restorable = candidate.turnEndSeq !== -1;
1856
+ let reason;
1857
+ if (candidate.source === 'head') {
1858
+ // The head is the current working set: returning to it discards
1859
+ // nothing and is never a meaningful return target.
1860
+ restorable = false;
1861
+ reason = 'the head is the current working set; returning to it discards nothing';
1862
+ }
1863
+ else if (sourceUsage === undefined) {
1864
+ // The source's cost cannot be measured (no meter, or the archived
1865
+ // ancestor cannot be borrowed): the budget cannot be proven, so the
1866
+ // candidate is not selectable. Never price an unknown as zero.
1867
+ restorable = false;
1868
+ reason = 'the source Session\'s context cost cannot be measured, so the return budget cannot be proven';
1869
+ }
1870
+ else if (candidate.source === 'handoff' || candidate.source === 'compaction') {
1871
+ // A handoff starts a generation and a compaction rewrites the visible
1872
+ // surface: rewinding into them is not a proven-safe V1 target.
1873
+ restorable = false;
1874
+ reason = `source '${candidate.source}' is not a restorable checkpoint`;
1875
+ }
1876
+ else if (candidate.source === 'team-boundary') {
1877
+ // A Team delivery is a selectable default checkpoint exactly when the
1878
+ // Host can prove it stays inside one Thread's context: the boundary
1879
+ // resolved at a completed turn AND exactly one Thread's facts entered
1880
+ // the Session context through it. Multi-Thread or unattributable
1881
+ // boundaries document why they are not selectable.
1882
+ if (affectedThreads.length !== 1) {
1883
+ restorable = false;
1884
+ reason = affectedThreads.length === 0
1885
+ ? 'no single Thread is attributable to this boundary'
1886
+ : 'multiple Threads entered the context through this boundary; write a fresh handoff instead';
1887
+ }
1888
+ else if (retainedTokens >= handoffAt) {
1889
+ restorable = false;
1890
+ reason = 'retained context would not materially shrink the working set';
1891
+ }
1892
+ }
1893
+ else if (candidate.source === 'agent' && retainedTokens >= handoffAt) {
1894
+ restorable = false;
1895
+ reason = 'retained context would be at or above the handoff budget';
1896
+ }
1897
+ return {
1898
+ checkpointRef: candidate.ref,
1899
+ name: candidate.label,
1900
+ source: candidate.source,
1901
+ retainedTokens,
1902
+ discardedTokens,
1903
+ affectedThreads,
1904
+ restorable,
1905
+ ...(reason === undefined ? {} : { reason }),
1906
+ ...(candidate.source === 'agent' ? {} : { sourceSessionId }),
1907
+ };
1908
+ }
1909
+ /**
1910
+ * Agent-only rollover request validation: the tool calls this to check its
1911
+ * Member binding, exclusivity, and checkpoint ownership. It performs no
1912
+ * lifecycle effect — the actual transition reacts to the successful tool
1913
+ * result through the context-management coordinator.
1914
+ */
1915
+ async requestNewContext(agent, request) {
1916
+ const member = this.memberForAgent(agent);
1917
+ if (member === undefined || member.state !== 'enabled')
1918
+ throw new Error('context_rollover requires an active Team Member');
1919
+ if (this.contextManagement.isTransitioning(member.memberId))
1920
+ throw new Error('a context rollover is already scheduled for this Member; wait for it to finish before requesting another');
1921
+ if (this.runningAgents.has(agent.id) !== true) {
1922
+ // The tool runs inside the Member's own turn, so a non-running agent at
1923
+ // this point is a harness anomaly; refuse rather than schedule a swap
1924
+ // outside the turn fence.
1925
+ throw new Error('context_rollover must run inside this Member\'s own running turn');
1926
+ }
1927
+ // Job ownership guard: disposing the old Agent cancels its running jobs
1928
+ // and orphaned terminal-but-unreported output would vanish with it. The
1929
+ // rejection names the jobs so the model can collect or stop them first;
1930
+ // the transition rechecks at the lifecycle commit seam because a job may
1931
+ // settle between this validation and the swap.
1932
+ const blocking = this.ownedJobsBlockingRollover(agent);
1933
+ if (blocking.length > 0) {
1934
+ throw new Error(`context_rollover is refused while this Member owns jobs that would not survive the switch (${blocking.join(', ')}); collect or stop them first, then retry`);
1935
+ }
1936
+ if (request.checkpointRef === undefined)
1937
+ return { mode: 'fresh' };
1938
+ if (!/^(context-checkpoint-[0-9a-f]{64}|team-boundary-[0-9a-f]{64})$/.test(request.checkpointRef))
1939
+ throw new Error('checkpointRef must be an opaque ref exactly as returned by context_timeline');
1940
+ // Full current-state prevalidation through the ONE resolver the swap
1941
+ // itself uses: a ref that is fabricated, unattributable, nonshrinking,
1942
+ // unmeasurable, over-budget, or blocked by multiple active Claims
1943
+ // rejects HERE — a model-visible error result instead of a fake
1944
+ // `scheduled` whose async swap always fails. The seed is resolved and
1945
+ // discarded: the transition seam resolves it again, so only the mutable
1946
+ // guard set (jobs, route limits, lineage growth) is revalidated there.
1947
+ await this.resolveCheckpointSeed(member.memberId, agent, request.checkpointRef);
1948
+ return { mode: 'from-checkpoint' };
1949
+ }
1950
+ /**
1951
+ * Jobs this Agent owns that cannot survive a generation swap: any
1952
+ * running/stopping job, and any settled job whose terminal output was
1953
+ * never reported (disposal would silently discard it). In-place hard
1954
+ * compaction is exempt — it never cancels the owner.
1955
+ */
1956
+ ownedJobsBlockingRollover(agent) {
1957
+ const jobs = agent.ctx.get('jobs');
1958
+ if (jobs === undefined)
1959
+ return [];
1960
+ return jobs.list(agent)
1961
+ .filter((job) => job.status === 'running' || job.status === 'stopping' || ((job.status === 'completed' || job.status === 'killed' || job.status === 'failed') && !job.reported))
1962
+ .map((job) => `${job.label} (${job.id})`);
1963
+ }
1964
+ /**
1965
+ * Agent-only session rollover commit: the Member actor must be the target
1966
+ * Member on its currently bound live Session. The Host performs the actual
1967
+ * generation swap around this write; the ledger records only the durable
1968
+ * binding transition and rollover audit envelope.
1969
+ */
1970
+ async rolloverSessionForAgent(agent, request) {
1971
+ const actor = this.memberCall(agent, request.workspaceId);
1972
+ const result = await this.requireLedger().rolloverMemberSession({ ...request, actor });
1973
+ if (result.committed)
1974
+ this.emitCommitted(result.value.receipt);
1975
+ return result.value;
1976
+ }
1009
1977
  emitCommittedOutcome(result) {
1010
1978
  if (result.committed && result.value.kind === 'committed' && result.value.receipt !== undefined)
1011
1979
  this.emitCommitted(result.value.receipt);
@@ -1084,16 +2052,40 @@ let AgentTeam = (() => {
1084
2052
  throw error;
1085
2053
  }
1086
2054
  }
1087
- async activateMember(member, knownWorkspacePath, knownSessions, forkedFrom) {
2055
+ /**
2056
+ * Session headers currently durable in the persistence backend.
2057
+ *
2058
+ * The Host retires a disposed Session's log without awaiting it, so a
2059
+ * concurrent activation can observe the JSONL backend's transient win32
2060
+ * staging directories (.dsh-mkdir-*) as ENOENT while they rename into
2061
+ * place. The read is idempotent; back off briefly instead of failing the
2062
+ * activation on a race the publisher resolves within milliseconds.
2063
+ */
2064
+ async persistedSessionHeaders() {
2065
+ for (let attempt = 0;; attempt += 1) {
2066
+ try {
2067
+ return await this.ctx.sessionPersistence.list();
2068
+ }
2069
+ catch (error) {
2070
+ if (attempt >= 3 || error?.code !== 'ENOENT')
2071
+ throw error;
2072
+ await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 25));
2073
+ }
2074
+ }
2075
+ }
2076
+ async activateMember(member, knownWorkspacePath, knownSessions, forkedFrom, options) {
1088
2077
  if (this.handles.has(member.memberId))
1089
2078
  return;
1090
2079
  let created;
1091
2080
  try {
1092
2081
  const workspace = this.requireWorkspace(member.workspaceId);
1093
2082
  const workspacePath = knownWorkspacePath ?? workspace.path;
1094
- await this.memberRuntime.initializePrivateMemory(member.privateMemoryPath);
2083
+ // Existing Members carry the pre-sanitization ledger path; activation
2084
+ // migrates it onto the sanitized directory before provisioning.
2085
+ const sanitizedMemoryPath = dshHomePath('agent-team', 'members', memberMemoryDirectoryName(member.memberId));
2086
+ await this.memberRuntime.initializePrivateMemory(sanitizedMemoryPath, member.privateMemoryPath);
1095
2087
  const persisted = knownSessions !== undefined ? knownSessions.has(member.sessionId)
1096
- : (await this.ctx.sessionPersistence.list()).some(header => header.id === member.sessionId);
2088
+ : await this.sessionPersisted(member.sessionId);
1097
2089
  // AgentOptions declares only provider/model. Install the full selection
1098
2090
  // through the public Agent model-selection seam so reasoning effort is
1099
2091
  // applied to the next request and not lost during activation.
@@ -1104,16 +2096,53 @@ let AgentTeam = (() => {
1104
2096
  // allow-list filters the catalog by name through the live ref below.
1105
2097
  // `swap` is bound by the provider at activation (no-op until then).
1106
2098
  const skillSelection = { current: member.capabilities?.skills?.allow, swap: () => { } };
1107
- const setup = async (agentCtx) => {
2099
+ const setup = async (agentCtx, agent) => {
1108
2100
  await this.ctx.agentPresets.mount(agentCtx, member.presetId);
1109
2101
  this.memberRuntime.applyMemberToolPolicy(agentCtx, member);
1110
2102
  this.validateMemberPreset(agentCtx);
1111
2103
  installModelSelection(agentCtx, selected);
2104
+ // Admission gate for pending context rollovers: once a successful
2105
+ // context_rollover result is durable, queued input must not open another
2106
+ // old-generation model request. The turn-stop boundary captures the
2107
+ // inbox (non-Team input is carried to the new generation), and a
2108
+ // racing pre-step rejects instead of admitting claimed messages.
2109
+ agentCtx.on('agent/turn-stopping', ({ agent }) => {
2110
+ if (!this.contextManagement.needsAdmissionGate(agent))
2111
+ return;
2112
+ this.contextManagement.captureQueuedInput(agent);
2113
+ });
2114
+ agentCtx.on('agent/pre-step', async ({ agent, messages, signal }, next) => {
2115
+ // Check before AND after the waterfall: a rollover pending at either
2116
+ // edge must reject this old-generation step, preserving its claimed
2117
+ // input for the new generation instead of letting it run or drop.
2118
+ if (this.contextManagement.needsAdmissionGate(agent)) {
2119
+ this.contextManagement.captureClaimedInput(agent, messages);
2120
+ return { kind: 'reject' };
2121
+ }
2122
+ // Team pressure policy rides the same pre-step seam after the
2123
+ // admission gate: the handoff-budget notice steers into the running
2124
+ // turn, and the hard limit forces compaction before the request is
2125
+ // forwarded — failing closed blocks the step instead of submitting
2126
+ // over the Team limit. Missing route capacity is an explicit reject.
2127
+ const pressure = await this.pressurePolicy.onPreStep(agent, signal);
2128
+ if (pressure.kind === 'reject')
2129
+ return { kind: 'reject' };
2130
+ const decision = await next();
2131
+ if (decision.kind === 'reject' || !this.contextManagement.needsAdmissionGate(agent))
2132
+ return decision;
2133
+ this.contextManagement.captureClaimedInput(agent, messages);
2134
+ return { kind: 'reject' };
2135
+ });
2136
+ // Provider context-overflow recovery: one bounded compact-and-retry
2137
+ // sequence per failure chain through the Team-owned policy.
2138
+ agentCtx.on('agent/request-error', async ({ agent, failure, signal }, next) => {
2139
+ const retry = await this.pressurePolicy.onRequestError(agent, failure, signal);
2140
+ if (retry)
2141
+ return { kind: 'retry' };
2142
+ return next();
2143
+ });
1112
2144
  return {
1113
2145
  commit: () => {
1114
- const agent = agentCtx.agent;
1115
- if (agent === undefined)
1116
- throw new Error('agent-team setup has no unpublished Agent');
1117
2146
  // The member scope composes no sandbox-policy service (the preset
1118
2147
  // owns no sandbox row), so read the last logged mode straight from
1119
2148
  // the session log; `sandbox/mode` is log-only and never joins the
@@ -1125,13 +2154,51 @@ let AgentTeam = (() => {
1125
2154
  },
1126
2155
  };
1127
2156
  };
2157
+ // A renewal's create path parents at the ledger-recorded previous
2158
+ // Session even when the caller does not pass one: a crash between the
2159
+ // durable rollover commit and this activation recreates the Session
2160
+ // from the ledger binding alone, and the lineage parent is what makes
2161
+ // the missing handoff reconstructible on the NEXT failure. A recorded
2162
+ // checkpoint seed re-seeds the child here — the crash lost the process
2163
+ // memory, but the ledger envelope names the exact source prefix.
2164
+ let recordedSeed;
2165
+ let recordedSeedCut;
2166
+ const rolloverSeed = !persisted && options?.seed === undefined
2167
+ ? this.requireLedger().rolloverSeedForMember(member.memberId, member.sessionId)
2168
+ : undefined;
2169
+ if (rolloverSeed !== undefined) {
2170
+ // Fail-closed: an unreadable or unprovable recorded seed rejects
2171
+ // this activation (the catch below records the diagnostic) instead
2172
+ // of silently downgrading a committed checkpoint return to a blank
2173
+ // child.
2174
+ const resolved = await this.recordedCheckpointPrefix(rolloverSeed);
2175
+ recordedSeed = resolved.prefix;
2176
+ recordedSeedCut = SessionLogOffset(resolved.prefix.length);
2177
+ }
2178
+ const lineageParent = forkedFrom ?? (!persisted ? this.requireLedger().previousSessionForMember(member.memberId) : undefined);
2179
+ let recoveryCarried = 0;
2180
+ const seededFromLedger = recordedSeed !== undefined;
2181
+ const effectiveParent = recordedSeed !== undefined ? rolloverSeed.sourceSessionId : lineageParent;
2182
+ const seedOptions = options?.seed !== undefined && options.inheritedEventCount !== undefined
2183
+ ? { seed: options.seed, inheritedEventCount: options.inheritedEventCount }
2184
+ : recordedSeed !== undefined && recordedSeedCut !== undefined
2185
+ ? { seed: recordedSeed, inheritedEventCount: recordedSeedCut }
2186
+ : {};
1128
2187
  created = persisted
1129
2188
  ? await this.ctx.agents.resume({ resumeSessionId: member.sessionId, agentOptions, setup })
1130
2189
  : await this.ctx.agents.create({
1131
2190
  sessionId: member.sessionId,
1132
2191
  // A context renewal records its fork lineage so the archived
1133
- // previous Session stays discoverable from the durable header.
1134
- meta: { cwd: workspacePath, agentPreset: member.presetId, ...(forkedFrom === undefined ? {} : { parentSession: forkedFrom }) },
2192
+ // previous Session stays discoverable from the durable header; a
2193
+ // checkpoint return parents at the seed source and marks the
2194
+ // fork seeded with its exact inherited prefix length.
2195
+ meta: {
2196
+ cwd: workspacePath,
2197
+ agentPreset: member.presetId,
2198
+ ...(effectiveParent === undefined ? {} : { parentSession: effectiveParent }),
2199
+ ...(options?.seed === undefined && !seededFromLedger ? {} : { isSeeded: true }),
2200
+ },
2201
+ ...seedOptions,
1135
2202
  agentOptions,
1136
2203
  setup,
1137
2204
  });
@@ -1142,14 +2209,74 @@ let AgentTeam = (() => {
1142
2209
  this.modelSelections.set(member.memberId, selected);
1143
2210
  this.clearMemberFailure(member.memberId, 'activation');
1144
2211
  this.nameMemberSession(member, created.agent);
1145
- this.notifyMember(created.agent);
1146
- this.autoCompaction.activated(member.memberId);
2212
+ // The one-shot pressure notice needs no explicit re-arm here: it latches
2213
+ // on durable Session evidence, and a fresh generation's own event span
2214
+ // starts empty — a new Session is itself the re-arm.
2215
+ // Snapshot whether this generation had already started its own turns
2216
+ // BEFORE any recovery delivery below: the handoff steer and the Inbox
2217
+ // wake legitimately append `turn/start` to this Session, and those must
2218
+ // never be mistaken for the generation having run on its own.
2219
+ const generationStarted = created.agent.session.ownEvents().some(event => event.type === 'turn/start');
2220
+ // A restart between a durable rollover intent and its swap replays the
2221
+ // old Session; the projection still carries the intent, so finish the
2222
+ // transition (or keep waiting for the containing turn) from here.
2223
+ if (persisted) {
2224
+ const state = foldContextProjection(created.agent.session.ownEvents(), created.agent.session.inheritedEventCount, created.agent.session.id);
2225
+ if (state.pending !== null)
2226
+ this.contextManagement.recoverPendingTransition(member.memberId, created.agent, member.sessionId);
2227
+ // A restart between one checkpoint's durable result and its quiet
2228
+ // follow-up delivery repairs exactly once; delivered continuations
2229
+ // stay delivered through the projection's own delivery record.
2230
+ this.contextManagement.repairContinuations(created.agent, state);
2231
+ // A restart after the rollover committed but before the handoff was
2232
+ // delivered activates the new Session with no handoff in its own
2233
+ // log — never treat that as an ordinary blank Member Session. The
2234
+ // operation's recorded previous Session (the lineage parent) still
2235
+ // holds the intent; rebuild the handoff from it. "No handoff in its
2236
+ // own log" is judged over both shapes: a generation rescued from the
2237
+ // retired custom kinds carries its handoff as a source the projection
2238
+ // does not classify, and rebuilding on top of it would inject the same
2239
+ // handoff twice.
2240
+ if (!handoffAlreadyInLog(state.boundaries, created.agent.session.ownEvents())) {
2241
+ await this.reconstructMissingHandoff(member, created.agent);
2242
+ }
2243
+ // Carried input redelivery binds to the committed transition target —
2244
+ // this Session — never to the handoff's presence: a crash after the
2245
+ // handoff landed but before the carried enqueue still replays here.
2246
+ recoveryCarried = await this.replayCarriedInput(member, created.agent, generationStarted);
2247
+ }
2248
+ else if (forkedFrom === undefined && lineageParent !== undefined) {
2249
+ // A restart recreated a Session that never materialized before the
2250
+ // crash (the rollover committed, its activation failed, and this
2251
+ // create path just rebuilt it from the ledger binding alone): the
2252
+ // same handoff reconstruction applies from the ledger's recorded
2253
+ // previous Session, and the old generation's unconsumed post-intent
2254
+ // input — the durable fold, the same truth the live transition uses —
2255
+ // rides behind the handoff exactly as it would have. The rollover's
2256
+ // own activation passes its fork parent explicitly and delivers the
2257
+ // handoff right after — that delivery is the plan, never this
2258
+ // recovery.
2259
+ await this.reconstructMissingHandoff(member, created.agent);
2260
+ recoveryCarried = await this.replayCarriedInput(member, created.agent, generationStarted);
2261
+ }
2262
+ // The ordinary Inbox wake runs LAST, after any recovery delivery above:
2263
+ // the handoff must stay the first model-facing context of a recovered
2264
+ // generation, with carried input behind it and rederived Team facts
2265
+ // behind that. When recovery redelivered carried input, the wake is
2266
+ // sequenced behind it (its own follow-up turn) so the steer lane can
2267
+ // never leapfrog the carried messages. A rollover activation defers the
2268
+ // wake entirely — its caller delivers the handoff (and carried input)
2269
+ // first and rederives the Inbox afterwards.
2270
+ if (options?.deferNotify !== true)
2271
+ this.notifyMember(created.agent, recoveryCarried > 0);
1147
2272
  }
1148
2273
  catch (error) {
1149
2274
  await created?.dispose();
1150
2275
  this.modelSelections.delete(member.memberId);
1151
2276
  this.memberRuntime.forgetMember(member.memberId);
1152
- this.setMemberFailure(member.memberId, 'activation', error instanceof Error ? error.message : String(error));
2277
+ const message = error instanceof Error ? error.message : String(error);
2278
+ this.ctx.logger.warn(`agent-team: activation failed for member '${this.memberLabel(member.memberId)}': ${message}`);
2279
+ this.setMemberFailure(member.memberId, 'activation', message);
1153
2280
  }
1154
2281
  finally {
1155
2282
  // Activation only changes this Workspace's presence projection.
@@ -1209,6 +2336,12 @@ let AgentTeam = (() => {
1209
2336
  const scope = scopeOf(agentCtx);
1210
2337
  const teamMessage = this.ctx.tools.get('team_message', scope);
1211
2338
  if (teamMessage?.[AGENT_TEAM_PRESET_MARKER] !== true) {
2339
+ // `agentPresets.mount` already rejected an unscoped context, so a scope
2340
+ // key the harness sees but this bundle does not means the two sides
2341
+ // loaded different module instances of @deepseek-ai/dsh-scope (the
2342
+ // common trigger is running the CLI from source via tsx).
2343
+ if (scope === undefined)
2344
+ throw new Error(teamPresetScopeMismatchMessage(isTsxDevMode()));
1212
2345
  throw new Error('selected preset is not team-enabled');
1213
2346
  }
1214
2347
  const available = new Set(this.ctx.tools.schemas(scope).map(tool => tool.name));
@@ -1229,6 +2362,13 @@ let AgentTeam = (() => {
1229
2362
  const handle = this.handles.get(member.memberId);
1230
2363
  if (handle === undefined)
1231
2364
  return Object.freeze({ member, availability: 'unavailable', presence: 'unavailable' });
2365
+ // A rollover commits its ledger binding before the old generation retires
2366
+ // and the new one activates; during that window the live handle still runs
2367
+ // the previous Session. The Member stays visible but must not report the
2368
+ // new binding as active — a Client following the row would otherwise open
2369
+ // a Session that does not exist yet.
2370
+ if (handle.agent.id !== member.sessionId)
2371
+ return Object.freeze({ member, availability: 'unavailable', presence: 'unavailable', diagnostic: 'context rollover in progress' });
1232
2372
  if (this.ctx.agentPresets.composedPreset(handle.agent.ctx) === undefined) {
1233
2373
  return Object.freeze({ member, availability: 'active', presence: 'error', diagnostic: ORPHANED_MEMBER_DIAGNOSTIC });
1234
2374
  }
@@ -1291,16 +2431,41 @@ let AgentTeam = (() => {
1291
2431
  if (handle !== undefined)
1292
2432
  this.notifyMember(handle.agent);
1293
2433
  }
1294
- const compactionMembers = acceptedTaskCompactionMembers(operation);
1295
- if (compactionMembers !== undefined)
1296
- this.autoCompaction.schedule(compactionMembers);
2434
+ // Task acceptance no longer schedules standalone auto compaction: it is
2435
+ // a semantic checkpoint/context cue (delivered as ordinary Team
2436
+ // notification), and the Team pressure policy owns compaction entry.
2437
+ }
2438
+ /** Model-visible active-Claim labels for the pressure notice. */
2439
+ activeClaimLabels(memberId) {
2440
+ const ledger = this.ledger;
2441
+ if (ledger === undefined)
2442
+ return [];
2443
+ const labels = [];
2444
+ for (const claim of ledger.activeClaimsForMember(memberId)) {
2445
+ if (claim.state !== 'active')
2446
+ continue;
2447
+ labels.push(`${claim.claimRef} (${claim.direction})`);
2448
+ }
2449
+ return labels;
2450
+ }
2451
+ /** Model-visible running/stopping job labels for the pressure notice. */
2452
+ runningJobLabels(memberId) {
2453
+ const handle = this.handles.get(memberId);
2454
+ if (handle === undefined)
2455
+ return [];
2456
+ const jobs = handle.agent.ctx.get('jobs');
2457
+ if (jobs === undefined)
2458
+ return [];
2459
+ return jobs.list(handle.agent)
2460
+ .filter((job) => job.status === 'running' || job.status === 'stopping')
2461
+ .map((job) => `${job.name ?? job.id}`);
1297
2462
  }
1298
2463
  emitAutoCompactionChanged(memberId) {
1299
2464
  const workspaceId = this.ledger?.getMember(memberId)?.workspaceId;
1300
2465
  this.emitChanged(workspaceId === undefined ? undefined : [{ kind: 'workspace', workspaceId }]);
1301
2466
  }
1302
2467
  /** Wake from durable unread state with bounded facts for direct and state-changing work. */
1303
- notifyMember(agent) {
2468
+ notifyMember(agent, sequenced = false) {
1304
2469
  const member = this.memberForAgent(agent);
1305
2470
  if (member === undefined || member.state !== 'enabled')
1306
2471
  return;
@@ -1309,6 +2474,12 @@ let AgentTeam = (() => {
1309
2474
  this.notifiedInbox.delete(member.memberId);
1310
2475
  return;
1311
2476
  }
2477
+ // Any ordinary next-turn input already queued (a rollover's or recovery's
2478
+ // carried messages) forces sequencing regardless of the caller: a steer
2479
+ // would claim the nearest step boundary and leapfrog the carried turn.
2480
+ if (!sequenced) {
2481
+ sequenced = agent.inbox.nextTurn.some(message => message.source.kind === 'user');
2482
+ }
1312
2483
  const signature = JSON.stringify(notifications.map(({ item }) => [
1313
2484
  item.thread.threadRef, item.thread.revision, item.unreadCount, item.directCount, item.newestSequence,
1314
2485
  ]));
@@ -1333,7 +2504,15 @@ let AgentTeam = (() => {
1333
2504
  });
1334
2505
  this.notifiedInbox.set(member.memberId, signature);
1335
2506
  try {
1336
- agent.steer(hint);
2507
+ // Steer normally claims the nearest step boundary — which would
2508
+ // preempt carried input that a rollover or crash recovery queued as
2509
+ // ordinary next-turn messages. The sequenced mode enqueues the
2510
+ // rederived Inbox as its own follow-up turn instead, so the delivery
2511
+ // order stays handoff → carried input → rederived Inbox.
2512
+ if (sequenced)
2513
+ agent.followup(hint);
2514
+ else
2515
+ agent.steer(hint);
1337
2516
  }
1338
2517
  catch (error) {
1339
2518
  this.clearMemberNotificationState(member.memberId);
@@ -1374,7 +2553,7 @@ let AgentTeam = (() => {
1374
2553
  if (direct && fact.kind === 'message') {
1375
2554
  const sender = fact.message.sender === AGENT_TEAM_HUMAN_MEMBER_ID
1376
2555
  ? 'human' : this.requireLedger().getMember(fact.message.sender)?.handle ?? fact.message.sender;
1377
- const detail = ['Direct Team mention', `From: ${sender}`, `Channel: ${item.channelRef}`,
2556
+ const detail = ['Direct Team mention', `Occurred at: ${formatTeamTimestamp(fact.occurredAt)}`, `From: ${sender}`, `Channel: ${item.channelRef}`,
1378
2557
  ...(item.task === undefined ? [] : [`Task: ${item.task.taskRef}`]),
1379
2558
  `Thread: ${item.thread.threadRef}`, `Message ref: ${fact.message.messageRef}`,
1380
2559
  `Message: ${this.boundedNotificationBody(fact.message.body)}`].join('\n');
@@ -1382,15 +2561,15 @@ let AgentTeam = (() => {
1382
2561
  detailedFactCount += 1;
1383
2562
  }
1384
2563
  else if (fact.kind === 'activity') {
1385
- const detail = `${this.activityNotification(fact.activity, readerId)}\nThread: ${item.thread.threadRef}`;
2564
+ const detail = `${this.activityNotification(fact.activity, readerId)}\nOccurred at: ${formatTeamTimestamp(fact.occurredAt)}\nThread: ${item.thread.threadRef}`;
1386
2565
  if (append(detail))
1387
2566
  detailedFactCount += 1;
1388
2567
  }
1389
2568
  }
1390
- const ordinaryCount = facts.filter(entry => entry.fact.kind === 'message' && !entry.direct).length;
1391
- if (ordinaryCount > 0) {
2569
+ const ordinary = facts.filter(entry => entry.fact.kind === 'message' && !entry.direct);
2570
+ if (ordinary.length > 0) {
1392
2571
  const route = item.task === undefined ? `Thread ${item.thread.threadRef}` : `Task ${item.task.taskRef}`;
1393
- append(`${route}: ${ordinaryCount} unread update${ordinaryCount === 1 ? '' : 's'}.`);
2572
+ append(`${route}: ${ordinary.length} unread update${ordinary.length === 1 ? '' : 's'} · newest at ${formatTeamTimestamp(ordinary.at(-1).fact.occurredAt)}.`);
1394
2573
  }
1395
2574
  }
1396
2575
  if (omitted)
@@ -1405,11 +2584,28 @@ let AgentTeam = (() => {
1405
2584
  activityNotification(activity, readerId) {
1406
2585
  const actor = activity.actor === AGENT_TEAM_HUMAN_MEMBER_ID
1407
2586
  ? 'human' : this.requireLedger().getMember(activity.actor)?.handle ?? activity.actor;
1408
- // Early acceptance completes the reader's own open Claim inside the accept
1409
- // operation: say so plainly, or the owner keeps working on a done Task.
1410
- if (activity.kind === 'accept' && activity.actor === AGENT_TEAM_HUMAN_MEMBER_ID && readerId !== undefined
1411
- && activity.completedClaimRefs?.some(claimRef => this.requireLedger().getClaim(claimRef)?.owner === readerId)) {
1412
- return `Team Task update\n${actor} accepted Task ${activity.taskRef} and your open Claim was completed with it. No further work is needed.`;
2587
+ // An acceptance concludes the reader's own Claims in two ways: a Claim
2588
+ // already finished before the accept was accepted (its work stands), and
2589
+ // a still-open Claim the accept completed atomically (no further work is
2590
+ // needed). Both must be said plainly, or the owner keeps working on a
2591
+ // done Task or never learns its contribution was accepted.
2592
+ if (activity.kind === 'accept' && activity.actor === AGENT_TEAM_HUMAN_MEMBER_ID && readerId !== undefined) {
2593
+ const acceptedOwn = (activity.acceptedClaimRefs ?? []).filter(claimRef => this.requireLedger().getClaim(claimRef)?.owner === readerId);
2594
+ const completedOwn = (activity.completedClaimRefs ?? []).filter(claimRef => this.requireLedger().getClaim(claimRef)?.owner === readerId);
2595
+ // The early-accept-only reader holds no finished Claim: every own Claim
2596
+ // was completed atomically, so the "finished Claim" clause's ref list
2597
+ // would be empty. Render the completed semantics alone instead of a
2598
+ // sentence with a dangling empty list.
2599
+ const finishedOwn = acceptedOwn.filter(claimRef => !completedOwn.includes(claimRef));
2600
+ if (completedOwn.length > 0 && finishedOwn.length > 0) {
2601
+ return `Team Task update\n${actor} accepted Task ${activity.taskRef}. Your Claim ${completedOwn.join(', ')} was completed with the acceptance, and your finished Claim ${finishedOwn.join(', ')} was accepted. No further work is needed.`;
2602
+ }
2603
+ if (completedOwn.length > 0) {
2604
+ return `Team Task update\n${actor} accepted Task ${activity.taskRef} and your open Claim ${completedOwn.join(', ')} was completed with it. No further work is needed.`;
2605
+ }
2606
+ if (acceptedOwn.length > 0) {
2607
+ return `Team Task update\n${actor} accepted Task ${activity.taskRef}; your finished Claim ${acceptedOwn.join(', ')} was accepted. No further work is needed.`;
2608
+ }
1413
2609
  }
1414
2610
  if (activity.kind === 'claim' || activity.kind === 'done' || activity.kind === 'release') {
1415
2611
  return `Team Task update\n${actor} ${activity.kind} Claim ${activity.claimRef} on Task ${activity.taskRef}.`;