evolcore 0.0.11 → 0.0.12

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 (102) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/codex-managed-hook.mjs +80 -0
  3. package/dist/agents/claude-runner.js +35 -2
  4. package/dist/agents/codex-app-server-client.js +120 -14
  5. package/dist/agents/codex-runner.js +273 -35
  6. package/dist/agents/ecagent-runner.js +36 -4
  7. package/dist/agents/gemini-runner.js +1 -0
  8. package/dist/aun/aid/client.js +11 -21
  9. package/dist/aun/aid/managed-operation.js +107 -0
  10. package/dist/aun/msg/group.js +81 -12
  11. package/dist/aun/msg/managed-operation.js +804 -0
  12. package/dist/aun/msg/mention-schema.js +20 -0
  13. package/dist/aun/msg/p2p.js +7 -0
  14. package/dist/channels/aun.js +288 -108
  15. package/dist/channels/daemon.js +13 -0
  16. package/dist/cli/agent-command.js +56 -8
  17. package/dist/cli/agent.js +170 -6
  18. package/dist/cli/aun-commands.js +270 -69
  19. package/dist/cli/bench.js +12 -3
  20. package/dist/cli/daemon-commands.js +31 -31
  21. package/dist/cli/fs-command.js +60 -0
  22. package/dist/cli/handoff-command.js +3 -0
  23. package/dist/cli/index.js +30 -2
  24. package/dist/cli/managed-command-guard.js +41 -0
  25. package/dist/cli/model.js +11 -4
  26. package/dist/cli/queue-command.js +11 -1
  27. package/dist/cli/response.js +71 -0
  28. package/dist/cli/restart-monitor.js +4 -18
  29. package/dist/cli/trigger-command.js +4 -0
  30. package/dist/config/builtin-roles.js +4 -0
  31. package/dist/config/contact-book-store.js +11 -3
  32. package/dist/config/contact-request-service.js +161 -15
  33. package/dist/config/mention-mode.js +8 -24
  34. package/dist/core/auth/agent-delegation.js +0 -1
  35. package/dist/core/auth/operation-authorizer.js +30 -7
  36. package/dist/core/auth/operation-catalog.js +187 -25
  37. package/dist/core/bootstrap-messages.js +18 -0
  38. package/dist/core/bootstrap-service.js +63 -4
  39. package/dist/core/capability/providers/claude-capability-provider.js +9 -31
  40. package/dist/core/capability/providers/codex-capability-provider.js +4 -15
  41. package/dist/core/capability/skill-discovery.js +55 -0
  42. package/dist/core/channel-loader.js +1 -1
  43. package/dist/core/command/cli-intent-parser.js +9 -0
  44. package/dist/core/command/command-handler.js +467 -6
  45. package/dist/core/command/connect-menu.js +15 -0
  46. package/dist/core/command/group-menu.js +4 -4
  47. package/dist/core/command/menu-catalog.js +366 -0
  48. package/dist/core/command/menu-handler.js +147 -35
  49. package/dist/core/command/menu-protocol.js +71 -0
  50. package/dist/core/command/slash-gate.js +5 -2
  51. package/dist/core/command/slash-handler.js +41 -21
  52. package/dist/core/daemon-file-cache.js +2 -1
  53. package/dist/core/data-migration.js +9 -2
  54. package/dist/core/evolagent.js +4 -2
  55. package/dist/core/handoff/dispatcher.js +5 -2
  56. package/dist/core/handoff/runtime.js +145 -21
  57. package/dist/core/handoff/store.js +24 -0
  58. package/dist/core/handoff/types.js +1 -0
  59. package/dist/core/message/logical-queue-bridge.js +1 -0
  60. package/dist/core/message/mention-schema.js +126 -0
  61. package/dist/core/message/message-bridge.js +236 -78
  62. package/dist/core/message/message-log.js +1 -0
  63. package/dist/core/message/message-queue.js +1 -0
  64. package/dist/core/message/response-engine.js +54 -36
  65. package/dist/core/permission/ec-command-parser.js +156 -10
  66. package/dist/core/permission/sandbox-runtime.js +22 -1
  67. package/dist/core/permission/tool-policy.js +73 -30
  68. package/dist/core/protected-paths.js +7 -2
  69. package/dist/core/session/session-manager.js +21 -2
  70. package/dist/eck/manifest-engine.js +39 -13
  71. package/dist/index.js +384 -98
  72. package/dist/ipc.js +218 -9
  73. package/dist/response-system/coordinator.js +6 -4
  74. package/dist/response-system/engines/v1/proactive-flow.js +26 -8
  75. package/dist/response-system/modes/single-session/index.js +14 -1
  76. package/dist/response-system/selector.js +6 -5
  77. package/dist/trigger/anomaly-store.js +4 -0
  78. package/dist/trigger/script-executor.js +1 -0
  79. package/dist/utils/codex-app-server-registry.js +90 -0
  80. package/dist/utils/codex-cli.js +5 -1
  81. package/dist/utils/cross-platform.js +15 -0
  82. package/dist/utils/npm-ops.js +5 -12
  83. package/dist/utils/tool-summary.js +11 -0
  84. package/kits/docs/channels/aun.md +1 -1
  85. package/kits/docs/context-assembly.md +2 -2
  86. package/kits/docs/evolcore/agent.md +11 -6
  87. package/kits/docs/evolcore/aid.md +14 -0
  88. package/kits/docs/prompt-loading-architecture.md +1 -2
  89. package/kits/docs/venues/group.md +1 -1
  90. package/kits/eck_manifest.json +0 -12
  91. package/kits/rules/04-relation.md +1 -1
  92. package/kits/rules/05-venue.md +2 -2
  93. package/kits/schemas/single-session.schema.1.json +3 -3
  94. package/kits/schemas/single-session.schema.2.json +3 -3
  95. package/kits/templates/roles/admin.json +22 -0
  96. package/kits/templates/roles/member.json +27 -0
  97. package/kits/templates/roles/visitor.json +33 -3
  98. package/kits/templates/system-fragments/bootstrap.md +1 -1
  99. package/kits/templates/system-fragments/channel.md +1 -1
  100. package/kits/templates/system-fragments/session.md +1 -1
  101. package/package.json +2 -2
  102. package/dist/core/command/evol-menu-version-gate.js +0 -39
@@ -10,7 +10,7 @@
10
10
  * - 只缓存"文件 → 解析后内容",不缓存按 vars 渲染后的结果。
11
11
  *
12
12
  * 策略(reload/重启永远全量失效,无视策略;策略只决定"平时每次读怎么检查"):
13
- * - on-reload:平时不检查,直接用缓存(kits 文件、persona)。靠 reload/重启刷新。
13
+ * - on-reload:平时不检查,直接用缓存(kits 文件)。靠 reload/重启刷新。
14
14
  * - manual:同 on-reload,额外支持显式 invalidate(file) 单刷。
15
15
  * - mtime:每次读 statSync 门控 size + mtime,变了自动重读(working.md、config.json)。
16
16
  *
@@ -35,6 +35,7 @@ function newCounters() {
35
35
  */
36
36
  const GROUP_CAPS = {
37
37
  'relation-prefs': 512,
38
+ 'eck-runtime': 512,
38
39
  };
39
40
  export class FileCache {
40
41
  cache = new Map();
@@ -1303,7 +1303,10 @@ function verifyArchiveContains(archivePath, sourcePaths) {
1303
1303
  .replace(/\/+$/, '');
1304
1304
  let entries;
1305
1305
  try {
1306
- entries = new Set(execFileSync(tar, ['-tzf', archivePath], { encoding: 'utf8' })
1306
+ entries = new Set(execFileSync(tar, ['-tzf', archivePath], {
1307
+ encoding: 'utf8',
1308
+ windowsHide: process.platform === 'win32',
1309
+ })
1307
1310
  .split('\n')
1308
1311
  .map(normalizeArchivePath)
1309
1312
  .filter(Boolean));
@@ -1401,7 +1404,11 @@ export function finalizeDataMigration(root, manifest) {
1401
1404
  const temporary = `${archivePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
1402
1405
  const tar = process.platform === 'win32' ? 'tar.exe' : 'tar';
1403
1406
  try {
1404
- execFileSync(tar, ['-czf', temporary, ...sourcePaths], { cwd: root, stdio: 'pipe' });
1407
+ execFileSync(tar, ['-czf', temporary, ...sourcePaths], {
1408
+ cwd: root,
1409
+ stdio: 'pipe',
1410
+ windowsHide: process.platform === 'win32',
1411
+ });
1405
1412
  verifyArchiveContains(temporary, sourcePaths);
1406
1413
  fs.renameSync(temporary, archivePath);
1407
1414
  }
@@ -276,7 +276,8 @@ export class EvolAgent {
276
276
  */
277
277
  getPersona() {
278
278
  const personaPath = path.join(agentPersonalDir(this.aid), 'persona.md');
279
- return fileCache.get(personaPath, (raw) => (raw === null ? null : (raw.trim() || null)), { policy: 'mtime', group: this.agentFilesGroup() });
279
+ const raw = fileCache.getText(personaPath, { policy: 'mtime', group: this.agentFilesGroup() });
280
+ return raw === null ? null : (raw.trim() || null);
280
281
  }
281
282
  /**
282
283
  * 读取 personal/memory/working.md 内容。走 fileCache(mtime 门控):
@@ -285,7 +286,8 @@ export class EvolAgent {
285
286
  */
286
287
  getWorkingMemory() {
287
288
  const workingPath = path.join(agentPersonalDir(this.aid), 'memory', 'working.md');
288
- return fileCache.get(workingPath, (raw) => (raw === null ? null : (raw.trim() || null)), { policy: 'mtime', group: this.agentFilesGroup() });
289
+ const raw = fileCache.getText(workingPath, { policy: 'mtime', group: this.agentFilesGroup() });
290
+ return raw === null ? null : (raw.trim() || null);
289
291
  }
290
292
  /** 清除本 agent 身份层缓存(reload 后重新读取)。只失效自己的文件组,不波及他人。 */
291
293
  invalidatePersonaCache() {
@@ -117,8 +117,9 @@ export class HandoffDispatcher {
117
117
  if (this.pausedAgents.has(selfAid))
118
118
  return false;
119
119
  const current = this.store.get(selfAid, next.handoff_id);
120
- if (!current || current.state !== 'queued' || current.attention_required)
121
- return current?.state === 'target_sent';
120
+ if (!current || current.state !== 'queued' || current.attention_required) {
121
+ return current?.state === 'target_sent' || current?.state === 'discarded';
122
+ }
122
123
  this.store.recordSendStarted(selfAid, current.handoff_id);
123
124
  try {
124
125
  const send = await this.sendWithinQueueDeadline(current);
@@ -171,6 +172,8 @@ export class HandoffDispatcher {
171
172
  await new Promise(resolve => setTimeout(resolve, this.retryDelaysMs[Math.min(attempt - 1, this.retryDelaysMs.length - 1)] ?? 0));
172
173
  }
173
174
  }
175
+ if (this.store.get(selfAid, next.handoff_id)?.state === 'discarded')
176
+ continue;
174
177
  if (!sent) {
175
178
  this.store.recordSendExhausted(selfAid, next.handoff_id);
176
179
  logger.error(`[Handoff] target send retries exhausted: ${next.handoff_id}`);
@@ -7,6 +7,7 @@ import { createRootCausation, deriveCausation, normalizeCausation } from '../cau
7
7
  import { recordCausationSpan } from '../causation/audit.js';
8
8
  import { logger } from '../../utils/logger.js';
9
9
  const HANDOFF_STALE_WARN_MS = 72 * 60 * 60 * 1000;
10
+ const HANDOFF_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
10
11
  const HANDOFF_LARGE_AGGREGATE_COUNT = 10;
11
12
  const HANDOFF_LARGE_AGGREGATE_CHARS = 50_000;
12
13
  export class HandoffTargetBlockedError extends Error {
@@ -43,6 +44,15 @@ export class TriggerHandoffRequiredForbiddenError extends Error {
43
44
  this.name = 'TriggerHandoffRequiredForbiddenError';
44
45
  }
45
46
  }
47
+ export class HandoffSessionReplacedError extends Error {
48
+ sessionId;
49
+ code = 'HANDOFF_SESSION_REPLACED';
50
+ constructor(sessionId) {
51
+ super(`handoff session was replaced during creation: ${sessionId}`);
52
+ this.sessionId = sessionId;
53
+ this.name = 'HandoffSessionReplacedError';
54
+ }
55
+ }
46
56
  export class HandoffRuntime {
47
57
  sessionManager;
48
58
  messageQueue;
@@ -52,6 +62,8 @@ export class HandoffRuntime {
52
62
  now;
53
63
  staleWarned = new Set();
54
64
  largeAggregateWarned = new Set();
65
+ lifecycleSequence = 0;
66
+ sessionDiscardSequence = new Map();
55
67
  constructor(sessionManager, messageQueue, sender, store = new HandoffStore(), options = {}) {
56
68
  this.sessionManager = sessionManager;
57
69
  this.messageQueue = messageQueue;
@@ -60,6 +72,7 @@ export class HandoffRuntime {
60
72
  this.dispatcher = new HandoffDispatcher(store, this.mutexes, sender, options);
61
73
  }
62
74
  async createOutbound(input) {
75
+ const operationSequence = ++this.lifecycleSequence;
63
76
  throwIfAborted(input.signal);
64
77
  const origin = await this.sessionManager.getSessionById(input.originSessionId);
65
78
  throwIfAborted(input.signal);
@@ -81,24 +94,29 @@ export class HandoffRuntime {
81
94
  if (input.explicitReturnPolicy === 'required' && input.causation?.trigger?.path.length) {
82
95
  throw new TriggerHandoffRequiredForbiddenError();
83
96
  }
84
- await this.dispatcher.expireTargetQueued(input.selfAid, target.id);
85
- throwIfAborted(input.signal);
86
- const head = this.store.listByTarget(input.selfAid, target.id, 'queued')[0];
87
- if (head?.attention_required) {
88
- throw new HandoffTargetBlockedError(head.handoff_id, head.attention_reason ?? 'UNKNOWN_ATTENTION');
89
- }
90
97
  const handoffCausation = deriveCausation(normalizeCausation(input.causation) ?? createRootCausation());
91
- const handoff = this.store.create({
92
- selfAid: input.selfAid,
93
- originSessionId: origin.id,
94
- originMessageId: input.originMessageId,
95
- targetSessionId: target.id,
96
- payload: input.payload,
97
- encrypt: input.encrypt,
98
- returnPolicy: input.explicitReturnPolicy ?? 'required',
99
- originAuthorization: input.originAuthorization,
100
- causation: handoffCausation,
101
- now: this.now?.(),
98
+ const handoff = await this.mutexes.forKey(this.targetKey(input.selfAid, target.id)).runExclusive(async () => {
99
+ const replacedSessionId = [origin.id, target.id].find(sessionId => ((this.sessionDiscardSequence.get(this.sessionKey(input.selfAid, sessionId)) ?? 0) > operationSequence));
100
+ if (replacedSessionId)
101
+ throw new HandoffSessionReplacedError(replacedSessionId);
102
+ this.dispatcher.expireQueued(input.selfAid, target.id);
103
+ throwIfAborted(input.signal);
104
+ const head = this.store.listByTarget(input.selfAid, target.id, 'queued')[0];
105
+ if (head?.attention_required) {
106
+ throw new HandoffTargetBlockedError(head.handoff_id, head.attention_reason ?? 'UNKNOWN_ATTENTION');
107
+ }
108
+ return this.store.create({
109
+ selfAid: input.selfAid,
110
+ originSessionId: origin.id,
111
+ originMessageId: input.originMessageId,
112
+ targetSessionId: target.id,
113
+ payload: input.payload,
114
+ encrypt: input.encrypt,
115
+ returnPolicy: input.explicitReturnPolicy ?? 'required',
116
+ originAuthorization: input.originAuthorization,
117
+ causation: handoffCausation,
118
+ now: this.now?.(),
119
+ });
102
120
  });
103
121
  recordCausationSpan(handoffCausation, 'handoff.request', {
104
122
  status: 'started',
@@ -106,6 +124,10 @@ export class HandoffRuntime {
106
124
  });
107
125
  await this.dispatcher.drain(input.selfAid, target.id);
108
126
  const dispatched = this.store.get(input.selfAid, handoff.handoff_id) ?? handoff;
127
+ const replacedSessionId = [origin.id, target.id].find(sessionId => ((this.sessionDiscardSequence.get(this.sessionKey(input.selfAid, sessionId)) ?? 0) > operationSequence));
128
+ if (dispatched.state === 'discarded' || replacedSessionId) {
129
+ throw new HandoffSessionReplacedError(replacedSessionId ?? target.id);
130
+ }
109
131
  if (dispatched.state === 'failed') {
110
132
  throw new HandoffDeliveryFailedError(dispatched.handoff_id);
111
133
  }
@@ -142,7 +164,9 @@ export class HandoffRuntime {
142
164
  }
143
165
  markBindingIncomplete(selfAid, handoffId) {
144
166
  const instance = this.store.get(selfAid, handoffId);
145
- if (instance && !instance.attention_required) {
167
+ if (instance
168
+ && (instance.state === 'target_context_pending' || instance.state === 'return_pending')
169
+ && !instance.attention_required) {
146
170
  this.store.markAttention(selfAid, handoffId, 'REPLY_BINDING_INCOMPLETE');
147
171
  }
148
172
  }
@@ -207,16 +231,31 @@ export class HandoffRuntime {
207
231
  role: origin.chatType === 'group' ? (origin.identity?.role ?? 'none') : undefined,
208
232
  sessionKeyField: origin.sessionKey,
209
233
  selfAID: origin.selfAID,
210
- onPersisted: () => this.store.recordOriginEnqueued(selfAid, handoffId, messageId),
234
+ onPersisted: () => {
235
+ if (this.store.get(selfAid, handoffId)?.state !== 'origin_queued') {
236
+ throw new Error(`handoff is no longer deliverable: ${handoffId}`);
237
+ }
238
+ this.store.recordOriginEnqueued(selfAid, handoffId, messageId);
239
+ },
211
240
  });
212
241
  }
213
242
  catch (error) {
214
- this.store.recordOriginEnqueueFailed(selfAid, handoffId, error instanceof Error ? error.message : String(error));
243
+ if (this.store.get(selfAid, handoffId)?.state === 'origin_queued') {
244
+ this.store.recordOriginEnqueueFailed(selfAid, handoffId, error instanceof Error ? error.message : String(error));
245
+ }
215
246
  }
216
247
  }
217
248
  buildPromptItem(message) {
218
249
  return this.buildPromptItems(message)[0] ?? null;
219
250
  }
251
+ isDiscardedDelivery(message) {
252
+ const delivery = message.handoffDelivery;
253
+ const selfAid = message.selfAID;
254
+ if (!delivery || !selfAid)
255
+ return false;
256
+ const ids = delivery.handoffIds?.length ? delivery.handoffIds : [delivery.handoffId];
257
+ return ids.length > 0 && ids.every(handoffId => this.store.get(selfAid, handoffId)?.state === 'discarded');
258
+ }
220
259
  buildPromptItems(message) {
221
260
  const delivery = message.handoffDelivery;
222
261
  const selfAid = message.selfAID;
@@ -367,9 +406,54 @@ export class HandoffRuntime {
367
406
  expired += this.dispatcher.expireQueued(selfAid).length;
368
407
  return expired;
369
408
  }
370
- async recover(selfAids) {
409
+ async discardSession(selfAid, sessionId) {
410
+ this.sessionDiscardSequence.set(this.sessionKey(selfAid, sessionId), ++this.lifecycleSequence);
411
+ const candidates = this.store.list(selfAid)
412
+ .filter(instance => (instance.origin_session_id === sessionId || instance.target_session_id === sessionId) && instance.state !== 'failed' && instance.state !== 'completed' && instance.state !== 'discarded');
413
+ let discarded = 0;
414
+ for (const instance of candidates) {
415
+ const result = await this.discardWithTargetLock(selfAid, instance.handoff_id, 'session_replaced', {
416
+ session_id: sessionId,
417
+ });
418
+ if (result)
419
+ discarded++;
420
+ }
421
+ return discarded;
422
+ }
423
+ async discardExpired(selfAids, retentionMs = HANDOFF_RETENTION_MS) {
424
+ const now = this.now?.() ?? Date.now();
425
+ let discarded = 0;
426
+ for (const selfAid of selfAids) {
427
+ const latestEventVersions = new Map();
428
+ for (const event of this.store.events(selfAid)) {
429
+ if (!event.handoff_id || event.to_version === undefined)
430
+ continue;
431
+ latestEventVersions.set(event.handoff_id, Math.max(latestEventVersions.get(event.handoff_id) ?? 0, event.to_version));
432
+ }
433
+ for (const instance of this.store.list(selfAid)) {
434
+ if (instance.state === 'failed' || instance.state === 'completed' || instance.state === 'discarded')
435
+ continue;
436
+ if (now - instance.created_at <= retentionMs)
437
+ continue;
438
+ if ((latestEventVersions.get(instance.handoff_id) ?? 0) !== instance.version)
439
+ continue;
440
+ const result = await this.discardWithTargetLock(selfAid, instance.handoff_id, 'retention_expired', {
441
+ retention_ms: retentionMs,
442
+ age_ms: now - instance.created_at,
443
+ }, instance.version, now);
444
+ if (result) {
445
+ discarded++;
446
+ logger.info(`[Handoff] discarded expired handoff: ${instance.handoff_id}`);
447
+ }
448
+ }
449
+ }
450
+ return discarded;
451
+ }
452
+ async recover(selfAids, options = {}) {
371
453
  const originEnqueues = [];
372
454
  for (const selfAid of selfAids) {
455
+ if (options.discardExpired !== false)
456
+ await this.discardExpired([selfAid]);
373
457
  // v1 initially represented return=none as return_pending and required an
374
458
  // empty manual return. It has already been shown to a target turn, so
375
459
  // finish it before version reconciliation instead of prompting again.
@@ -412,6 +496,40 @@ export class HandoffRuntime {
412
496
  }
413
497
  await Promise.all(originEnqueues);
414
498
  }
499
+ async discardWithTargetLock(selfAid, handoffId, reason, details, expectedVersion, now = this.now?.()) {
500
+ const snapshot = this.store.get(selfAid, handoffId);
501
+ if (!snapshot)
502
+ return false;
503
+ const discarded = await this.mutexes.forKey(this.targetKey(selfAid, snapshot.target_session_id)).runExclusive(async () => {
504
+ const current = this.store.get(selfAid, handoffId);
505
+ if (!current || current.state === 'failed' || current.state === 'completed' || current.state === 'discarded') {
506
+ return null;
507
+ }
508
+ if (expectedVersion !== undefined && current.version !== expectedVersion)
509
+ return null;
510
+ this.store.discard(selfAid, handoffId, reason, details, now);
511
+ return current;
512
+ });
513
+ if (!discarded)
514
+ return false;
515
+ await this.cancelOriginDelivery(discarded);
516
+ return true;
517
+ }
518
+ async cancelOriginDelivery(instance) {
519
+ if (instance.state !== 'origin_queued' && instance.state !== 'origin_delivered')
520
+ return;
521
+ const messageId = instance.origin_delivery_message_id
522
+ ?? `origin-deliver:${instance.handoff_id}:${instance.origin_session_id}`;
523
+ const queue = this.messageQueue;
524
+ try {
525
+ const queued = queue.cancelMessageByIdInSession?.(instance.origin_session_id, messageId) ?? false;
526
+ if (!queued)
527
+ await queue.cancelActiveAndWait?.(messageId);
528
+ }
529
+ catch (error) {
530
+ logger.warn(`[Handoff] failed to cancel discarded origin delivery ${messageId}:`, error);
531
+ }
532
+ }
415
533
  warnIfStale(instance) {
416
534
  if (instance.state !== 'target_sent' && instance.state !== 'return_pending')
417
535
  return;
@@ -425,6 +543,12 @@ export class HandoffRuntime {
425
543
  + ` ageHours=${Math.floor(ageMs / 3_600_000)}`
426
544
  + ` originSession=${instance.origin_session_id} targetSession=${instance.target_session_id}`);
427
545
  }
546
+ targetKey(selfAid, targetSessionId) {
547
+ return `${selfAid}\u0000${targetSessionId}`;
548
+ }
549
+ sessionKey(selfAid, sessionId) {
550
+ return `${selfAid}\u0000${sessionId}`;
551
+ }
428
552
  warnIfLargeAggregate(message, instances, direction) {
429
553
  const chars = message.content.length
430
554
  + instances.reduce((total, instance) => total + this.requestSummary(instance).length, 0);
@@ -9,6 +9,7 @@ import { HANDOFF_SCHEMA_VERSION, HANDOFF_QUERY_DEFAULT_LIMIT, HANDOFF_QUERY_MAX_
9
9
  import { normalizeCausation } from '../causation/context.js';
10
10
  const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/;
11
11
  export const HANDOFF_ID_RE = /^h-[A-Za-z0-9._-]+$/;
12
+ const TERMINAL_HANDOFF_STATES = new Set(['failed', 'completed', 'discarded']);
12
13
  function safeSegment(value, field) {
13
14
  if (!value || !SAFE_ID_RE.test(value) || value === '.' || value === '..') {
14
15
  throw new Error(`invalid ${field}`);
@@ -514,6 +515,29 @@ export class HandoffStore {
514
515
  this.write(selfAid, instance);
515
516
  return instance;
516
517
  }
518
+ discard(selfAid, handoffId, reason, details = {}, now = Date.now()) {
519
+ const instance = this.get(selfAid, handoffId);
520
+ if (!instance)
521
+ throw new Error('handoff not found');
522
+ if (TERMINAL_HANDOFF_STATES.has(instance.state))
523
+ return instance;
524
+ const previousState = instance.state;
525
+ const previousVersion = instance.version;
526
+ instance.state = 'discarded';
527
+ instance.attention_required = false;
528
+ instance.attention_reason = null;
529
+ instance.version++;
530
+ instance.updated_at = now;
531
+ instance.completed_at = now;
532
+ this.appendEvent(selfAid, 'handoff_discarded', handoffId, {
533
+ fromVersion: previousVersion,
534
+ toVersion: instance.version,
535
+ details: { ...details, reason, previous_state: previousState },
536
+ now,
537
+ });
538
+ this.write(selfAid, instance);
539
+ return instance;
540
+ }
517
541
  requireState(selfAid, handoffId, expected) {
518
542
  const instance = this.get(selfAid, handoffId);
519
543
  if (!instance)
@@ -8,6 +8,7 @@ export const HANDOFF_STATES = [
8
8
  'origin_queued',
9
9
  'origin_delivered',
10
10
  'completed',
11
+ 'discarded',
11
12
  ];
12
13
  export const HANDOFF_QUERY_DEFAULT_LIMIT = 100;
13
14
  export const HANDOFF_QUERY_MAX_LIMIT = 500;
@@ -110,6 +110,7 @@ export class LogicalQueueBridge {
110
110
  peerId: message.peerId,
111
111
  content: message.content,
112
112
  chatType: message.chatType ?? 'private',
113
+ mentions: message.mentions,
113
114
  isMentioned: message.isMentioned,
114
115
  mentionAids: message.mentionAids,
115
116
  };
@@ -0,0 +1,126 @@
1
+ export class MentionSchemaError extends Error {
2
+ code = 'INVALID_MENTION_SCHEMA';
3
+ index;
4
+ constructor(message, index) {
5
+ super(index === undefined ? message : `mentions[${index}]: ${message}`);
6
+ this.name = 'MentionSchemaError';
7
+ this.index = index;
8
+ }
9
+ }
10
+ function isRecord(value) {
11
+ return !!value && typeof value === 'object' && !Array.isArray(value);
12
+ }
13
+ function hasOwn(value, key) {
14
+ return Object.prototype.hasOwnProperty.call(value, key);
15
+ }
16
+ function assertMetadata(value, index) {
17
+ if (hasOwn(value, 'display') && typeof value.display !== 'string') {
18
+ throw new MentionSchemaError('display must be a string', index);
19
+ }
20
+ for (const key of ['offset', 'length']) {
21
+ if (!hasOwn(value, key))
22
+ continue;
23
+ const field = value[key];
24
+ if (typeof field !== 'number' || !Number.isInteger(field) || field < 0) {
25
+ throw new MentionSchemaError(`${key} must be a non-negative integer`, index);
26
+ }
27
+ }
28
+ for (const key of ['name', 'key']) {
29
+ if (hasOwn(value, key) && typeof value[key] !== 'string') {
30
+ throw new MentionSchemaError(`${key} must be a string`, index);
31
+ }
32
+ }
33
+ }
34
+ /**
35
+ * Validate and copy canonical mention entries.
36
+ *
37
+ * This is intentionally strict: the old string array format is rejected
38
+ * instead of being converted, because conversion can hide a producer bug and
39
+ * make routing appear to work with the wrong wire contract.
40
+ */
41
+ export function normalizeMentionEntries(raw) {
42
+ if (raw === undefined)
43
+ return [];
44
+ if (!Array.isArray(raw)) {
45
+ throw new MentionSchemaError('mentions must be an array');
46
+ }
47
+ const normalized = [];
48
+ for (const [index, value] of raw.entries()) {
49
+ if (typeof value === 'string') {
50
+ throw new MentionSchemaError('legacy string entries are not supported; use {aid} or {scope:"all"}', index);
51
+ }
52
+ if (!isRecord(value)) {
53
+ throw new MentionSchemaError('entry must be an object', index);
54
+ }
55
+ const hasScope = hasOwn(value, 'scope');
56
+ const hasAid = hasOwn(value, 'aid');
57
+ const hasUserId = hasOwn(value, 'userId');
58
+ const discriminatorCount = Number(hasScope) + Number(hasAid) + Number(hasUserId);
59
+ if (discriminatorCount !== 1) {
60
+ throw new MentionSchemaError('entry must contain exactly one of scope, aid, or userId', index);
61
+ }
62
+ assertMetadata(value, index);
63
+ if (hasScope) {
64
+ if (value.scope !== 'all') {
65
+ throw new MentionSchemaError('scope must be "all"', index);
66
+ }
67
+ normalized.push({ ...value, scope: 'all' });
68
+ continue;
69
+ }
70
+ if (hasAid) {
71
+ if (typeof value.aid !== 'string' || value.aid.length === 0 || value.aid.trim() !== value.aid) {
72
+ throw new MentionSchemaError('aid must be a non-empty trimmed string', index);
73
+ }
74
+ if (value.aid === 'all') {
75
+ throw new MentionSchemaError('all is a scope, not an aid; use {scope:"all"}', index);
76
+ }
77
+ normalized.push({ ...value, aid: value.aid });
78
+ continue;
79
+ }
80
+ if (typeof value.userId !== 'string' || value.userId.length === 0 || value.userId.trim() !== value.userId) {
81
+ throw new MentionSchemaError('userId must be a non-empty trimmed string', index);
82
+ }
83
+ if (value.userId === 'all') {
84
+ throw new MentionSchemaError('all is a scope, not a userId; use {scope:"all"}', index);
85
+ }
86
+ normalized.push({ ...value, userId: value.userId });
87
+ }
88
+ return normalized;
89
+ }
90
+ export function mentionEntryIsAll(entry) {
91
+ return 'scope' in entry && entry.scope === 'all';
92
+ }
93
+ /** Return all concrete target IDs, including channel-native userId entries. */
94
+ export function mentionEntryTargets(entries) {
95
+ const targets = [];
96
+ const seen = new Set();
97
+ for (const entry of entries) {
98
+ const target = 'aid' in entry && typeof entry.aid === 'string'
99
+ ? entry.aid
100
+ : 'userId' in entry && typeof entry.userId === 'string'
101
+ ? entry.userId
102
+ : undefined;
103
+ if (!target || target === 'all' || seen.has(target))
104
+ continue;
105
+ seen.add(target);
106
+ targets.push(target);
107
+ }
108
+ return targets;
109
+ }
110
+ export function hasMentionAll(entries) {
111
+ return entries.some(mentionEntryIsAll);
112
+ }
113
+ /** IDs used by renderers; keeps `all` visible as a derived value. */
114
+ export function mentionEntryAids(entries) {
115
+ const aids = mentionEntryTargets(entries);
116
+ if (hasMentionAll(entries))
117
+ aids.push('all');
118
+ return aids;
119
+ }
120
+ export function mentionEntryKey(entry) {
121
+ if ('scope' in entry && entry.scope === 'all')
122
+ return 'scope:all';
123
+ if ('aid' in entry)
124
+ return `aid:${entry.aid}`;
125
+ return `user:${entry.userId}`;
126
+ }