@borgee/agents-host 0.2.2 → 0.2.28

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 (84) hide show
  1. package/README.md +184 -21
  2. package/dist/agents-host-supervisor.d.ts +7 -5
  3. package/dist/agents-host-supervisor.js +24 -4
  4. package/dist/agents-host.d.ts +114 -15
  5. package/dist/agents-host.js +2712 -141
  6. package/dist/chat/chat-control-plane.d.ts +13 -2
  7. package/dist/chat/sdk-chat-control-plane.d.ts +14 -3
  8. package/dist/chat/sdk-chat-control-plane.js +54 -2
  9. package/dist/cli-args.d.ts +46 -5
  10. package/dist/cli-args.js +313 -32
  11. package/dist/cli.d.ts +9 -0
  12. package/dist/cli.js +112 -5
  13. package/dist/compatibility-gates.d.ts +39 -0
  14. package/dist/compatibility-gates.js +131 -0
  15. package/dist/config.d.ts +1 -0
  16. package/dist/config.js +23 -5
  17. package/dist/connections-state-store.d.ts +81 -0
  18. package/dist/connections-state-store.js +228 -0
  19. package/dist/context/attention.d.ts +12 -0
  20. package/dist/context/attention.js +137 -0
  21. package/dist/context/collaboration-capabilities-diagnostics.d.ts +3 -0
  22. package/dist/context/collaboration-capabilities-diagnostics.js +18 -0
  23. package/dist/context/collaboration-outcome.d.ts +2 -0
  24. package/dist/context/collaboration-outcome.js +26 -0
  25. package/dist/context/injection.d.ts +134 -0
  26. package/dist/context/injection.js +355 -0
  27. package/dist/context/prompt.d.ts +4 -1
  28. package/dist/context/prompt.js +231 -1
  29. package/dist/context/task-thread-collaboration.d.ts +6 -0
  30. package/dist/context/task-thread-collaboration.js +31 -0
  31. package/dist/context/turn-preparation.d.ts +9 -0
  32. package/dist/context/turn-preparation.js +135 -0
  33. package/dist/debug.d.ts +44 -0
  34. package/dist/debug.js +135 -0
  35. package/dist/gateway/localhost-gateway.d.ts +52 -0
  36. package/dist/gateway/localhost-gateway.js +857 -0
  37. package/dist/index.js +7 -5
  38. package/dist/local-config.d.ts +4 -1
  39. package/dist/local-config.js +24 -7
  40. package/dist/managed-daemon-log.d.ts +34 -0
  41. package/dist/managed-daemon-log.js +261 -0
  42. package/dist/managed-daemon.d.ts +220 -0
  43. package/dist/managed-daemon.js +1601 -0
  44. package/dist/policy/authorization-audit.d.ts +63 -0
  45. package/dist/policy/authorization-audit.js +94 -0
  46. package/dist/policy/copilot-permission.d.ts +15 -0
  47. package/dist/policy/copilot-permission.js +193 -0
  48. package/dist/policy/gateway-authorization.d.ts +42 -0
  49. package/dist/policy/gateway-authorization.js +162 -0
  50. package/dist/providers/awaiting-user.d.ts +12 -0
  51. package/dist/providers/awaiting-user.js +192 -0
  52. package/dist/providers/claude/adapter.d.ts +3 -1
  53. package/dist/providers/claude/adapter.js +8 -12
  54. package/dist/providers/claude/cli-client.d.ts +12 -5
  55. package/dist/providers/claude/cli-client.js +184 -37
  56. package/dist/providers/claude/session-store.d.ts +1 -0
  57. package/dist/providers/codex/adapter.d.ts +11 -0
  58. package/dist/providers/codex/adapter.js +19 -0
  59. package/dist/providers/codex/cli-client.d.ts +103 -0
  60. package/dist/providers/codex/cli-client.js +1133 -0
  61. package/dist/providers/codex/project-doc.d.ts +3 -0
  62. package/dist/providers/codex/project-doc.js +90 -0
  63. package/dist/providers/codex/session-store.d.ts +38 -0
  64. package/dist/providers/codex/session-store.js +150 -0
  65. package/dist/providers/copilot/adapter.d.ts +3 -1
  66. package/dist/providers/copilot/adapter.js +8 -12
  67. package/dist/providers/copilot/cli-client.d.ts +20 -2
  68. package/dist/providers/copilot/cli-client.js +251 -71
  69. package/dist/providers/copilot/session-store.d.ts +1 -0
  70. package/dist/providers/create-provider.d.ts +11 -2
  71. package/dist/providers/create-provider.js +131 -12
  72. package/dist/run.d.ts +1 -0
  73. package/dist/run.js +5 -2
  74. package/dist/state-paths.d.ts +14 -1
  75. package/dist/state-paths.js +87 -3
  76. package/dist/task-thread-resolution.d.ts +10 -0
  77. package/dist/task-thread-resolution.js +48 -0
  78. package/dist/types.d.ts +267 -1
  79. package/dist/visible-mentions.d.ts +3 -0
  80. package/dist/visible-mentions.js +15 -0
  81. package/package.json +19 -17
  82. package/skills/borgee-agent/SKILL.md +33 -0
  83. package/skills/borgee-agent/borgee-agent.mjs +473 -0
  84. package/skills/borgee-agent/borgee-agent.py +409 -0
@@ -1,33 +1,280 @@
1
- import { chmod, mkdir } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, link, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
2
4
  import { SdkChatControlPlane } from './chat/sdk-chat-control-plane.js';
5
+ import { HostLogger, summarizeError } from './debug.js';
3
6
  import { createDurableCursorStore } from './durable-cursor-store.js';
7
+ import { ATTENTION_FOLLOW_SEMANTICS_COMPATIBILITY_GATE, COLLABORATION_CAPABILITIES_DIAGNOSTICS_COMPATIBILITY_GATE, COLLABORATION_OUTCOME_MODEL_COMPATIBILITY_GATE, COLLABORATION_SKILL_FIRST_COMPATIBILITY_GATE, CONTEXT_INJECTION_COMPATIBILITY_GATE, LOCALHOST_GATEWAY_COMPATIBILITY_GATE, POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE, resolveInternalPolicyMode, resolveInternalCompatibilityGates, SKILL_RUNTIME_COMPATIBILITY_GATE, TASK_THREAD_COLLABORATION_CONTRACT_COMPATIBILITY_GATE, TOKEN_BINDING_COMPATIBILITY_GATE, } from './compatibility-gates.js';
8
+ import { buildNormalizedAttentionSnapshot, parsePersistedAttentionSnapshot, } from './context/attention.js';
9
+ import { projectTaskThreadCollaborationContract } from './context/task-thread-collaboration.js';
10
+ import { createLocalhostGatewayController, LOCALHOST_GATEWAY_COLLABORATION_TARGET_COOLDOWN_MS, } from './gateway/localhost-gateway.js';
11
+ import { extractTaskIdFromTaskAssignmentContent, resolveChannelContextPayloadPath, } from './context/injection.js';
12
+ import { AuthorizationAuditSink, } from './policy/authorization-audit.js';
4
13
  import { createProvider } from './providers/create-provider.js';
14
+ import { resolveAttentionStatePath, resolveSharedProtocolKickoffDecisionPath, resolveSharedProtocolStatusPath, } from './state-paths.js';
15
+ import { appendVisibleMention as appendVisibleBodyMention, extractVisibleMentionIds as extractCanonicalMentionIds, } from './visible-mentions.js';
16
+ import { waitForTaskForThread } from './task-thread-resolution.js';
5
17
  const PRIVATE_STATE_ROOT_MODE = 0o700;
6
18
  const STREAM_PROGRESS_EDIT_THROTTLE_MS = 150;
19
+ const PROTOCOL_STATE_DIRNAME = 'protocol-state';
20
+ const PROTOCOL_PROVIDER_TURN_TIMEOUT_MS = 120_000;
21
+ const PROTOCOL_KICKOFF_DECISION_TIMEOUT_MS = PROTOCOL_PROVIDER_TURN_TIMEOUT_MS;
22
+ const PROTOCOL_KICKOFF_DECISION_POLL_INTERVAL_MS = 50;
23
+ const PROTOCOL_KICKOFF_ROUND_LIMIT = 20;
24
+ const COLLABORATION_LATE_SEND_GRACE_MS = 5_000;
25
+ const ORDINARY_HUMAN_EVENT_KINDS = new Set(['', 'message']);
7
26
  async function ensurePrivateStateRoot(path) {
8
27
  await mkdir(path, { recursive: true, mode: PRIVATE_STATE_ROOT_MODE });
9
28
  await chmod(path, PRIVATE_STATE_ROOT_MODE);
10
29
  }
30
+ function matchesProtocolStateIdentity(left, right) {
31
+ return (left.anchorMessageId === right.anchorMessageId &&
32
+ left.issuerId === right.issuerId &&
33
+ left.participantIds[0] === right.participantIds[0] &&
34
+ left.participantIds[1] === right.participantIds[1]);
35
+ }
36
+ function shouldAdoptPeerProtocolState(current, candidate, selfAgentId) {
37
+ if (!matchesProtocolStateIdentity(current, candidate)) {
38
+ return false;
39
+ }
40
+ if (candidate.currentTurnIndex !== current.currentTurnIndex) {
41
+ return candidate.currentTurnIndex > current.currentTurnIndex;
42
+ }
43
+ if ((candidate.finished && !current.finished) || (!candidate.active && current.active)) {
44
+ return true;
45
+ }
46
+ if (!current.lastProtocolMessageId && candidate.lastProtocolMessageId) {
47
+ return true;
48
+ }
49
+ if (current.expectedNextSpeakerId !== selfAgentId &&
50
+ candidate.expectedNextSpeakerId === selfAgentId) {
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ async function writePrivateJsonFile(path, value, options) {
56
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
57
+ const content = `${JSON.stringify(value, null, 2)}\n`;
58
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
59
+ await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
60
+ try {
61
+ if (options?.exclusive) {
62
+ await link(temporaryPath, path);
63
+ await unlink(temporaryPath);
64
+ return;
65
+ }
66
+ await rename(temporaryPath, path);
67
+ }
68
+ catch (error) {
69
+ await unlink(temporaryPath).catch(() => { });
70
+ throw error;
71
+ }
72
+ }
73
+ class DefaultHostRuntime {
74
+ config;
75
+ provider;
76
+ controlPlane;
77
+ ensureStateRoot;
78
+ gateway;
79
+ authorizationAuditSink;
80
+ logger;
81
+ constructor(config, deps) {
82
+ this.config = config;
83
+ this.logger = deps.logger;
84
+ const compatibilityGates = resolveInternalCompatibilityGates();
85
+ const contextInjectionGateEnabled = compatibilityGates.has(CONTEXT_INJECTION_COMPATIBILITY_GATE);
86
+ const skillRuntimeGateEnabled = compatibilityGates.has(SKILL_RUNTIME_COMPATIBILITY_GATE);
87
+ const localhostGatewayGateEnabled = contextInjectionGateEnabled &&
88
+ skillRuntimeGateEnabled &&
89
+ compatibilityGates.has(LOCALHOST_GATEWAY_COMPATIBILITY_GATE);
90
+ const tokenBindingGateEnabled = compatibilityGates.has(TOKEN_BINDING_COMPATIBILITY_GATE);
91
+ const policyAuditGateEnabled = compatibilityGates.has(POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE);
92
+ const policyMode = resolveInternalPolicyMode(policyAuditGateEnabled);
93
+ const collaborationEnabled = localhostGatewayGateEnabled &&
94
+ compatibilityGates.has(COLLABORATION_SKILL_FIRST_COMPATIBILITY_GATE) &&
95
+ policyAuditGateEnabled &&
96
+ policyMode === 'enforce';
97
+ this.authorizationAuditSink = policyAuditGateEnabled
98
+ ? new AuthorizationAuditSink({
99
+ stateRootDir: config.stateRootDir,
100
+ logger: deps.logger,
101
+ })
102
+ : undefined;
103
+ this.controlPlane =
104
+ deps.borgee ??
105
+ new SdkChatControlPlane(config.borgeeBaseUrl, config.agent.agentApiKey, undefined, {
106
+ cursorStore: createDurableCursorStore({
107
+ stateRootDir: config.stateRootDir,
108
+ }),
109
+ pluginId: `agents-host:${config.agent.provider}`,
110
+ });
111
+ this.gateway = createLocalhostGatewayController({
112
+ gateEnabled: localhostGatewayGateEnabled,
113
+ collaborationEnabled,
114
+ tokenBindingGateEnabled,
115
+ policyAuditGateEnabled,
116
+ policyMode,
117
+ controlPlane: this.controlPlane,
118
+ stateRootDir: config.stateRootDir,
119
+ resolveStableAgentId: deps.resolveStableAgentId,
120
+ logger: deps.logger,
121
+ auditSink: this.authorizationAuditSink,
122
+ authorizeCollaborationSend: deps.authorizeCollaborationSend,
123
+ readCollaborationDraft: deps.readCollaborationDraft,
124
+ });
125
+ this.provider =
126
+ deps.provider ??
127
+ createProvider({
128
+ provider: config.agent.provider,
129
+ stateRootDir: config.stateRootDir,
130
+ resolveStableAgentId: deps.resolveStableAgentId,
131
+ claudeCommand: config.claudeCommand,
132
+ claudeArgs: config.claudeArgs,
133
+ codexCommand: config.codexCommand,
134
+ codexArgs: config.codexArgs,
135
+ copilotCommand: config.copilotCommand,
136
+ copilotArgs: config.copilotArgs,
137
+ copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
138
+ }, deps.logger, {
139
+ compatibilityGates,
140
+ authorizationAuditSink: this.authorizationAuditSink,
141
+ localhostGateway: localhostGatewayGateEnabled ? this.gateway.contextPublisher : undefined,
142
+ });
143
+ this.ensureStateRoot = deps.ensureStateRoot ?? ensurePrivateStateRoot;
144
+ }
145
+ async start(onMessage) {
146
+ await this.ensureStateRoot(this.config.stateRootDir);
147
+ await this.gateway.start();
148
+ try {
149
+ await this.controlPlane.connect(onMessage);
150
+ }
151
+ catch (error) {
152
+ await this.gateway.stop().catch(() => { });
153
+ throw error;
154
+ }
155
+ }
156
+ async stop() {
157
+ let thrown;
158
+ try {
159
+ await this.controlPlane.close();
160
+ }
161
+ catch (error) {
162
+ thrown = error;
163
+ }
164
+ try {
165
+ await this.gateway.stop();
166
+ }
167
+ catch (error) {
168
+ thrown ??= error;
169
+ }
170
+ try {
171
+ await this.provider.dispose?.();
172
+ }
173
+ catch (error) {
174
+ thrown ??= error;
175
+ }
176
+ try {
177
+ await this.authorizationAuditSink?.drain();
178
+ }
179
+ catch (error) {
180
+ this.logger.error('authorization audit drain failed during shutdown', {
181
+ error: summarizeError(error),
182
+ });
183
+ }
184
+ if (thrown) {
185
+ throw thrown;
186
+ }
187
+ }
188
+ }
189
+ function createDefaultHostRuntime(config, deps) {
190
+ return new DefaultHostRuntime(config, deps);
191
+ }
11
192
  function hasVisibleText(value) {
12
193
  return value.trim().length > 0;
13
194
  }
195
+ function extractDispatchMentionIds(content) {
196
+ return [
197
+ ...content.matchAll(/(?:^|[^A-Za-z0-9_])@([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-HJKMNP-TV-Z]{26})/gu),
198
+ ]
199
+ .map((match) => match[1]?.trim())
200
+ .filter((value) => Boolean(value));
201
+ }
202
+ function hasCanonicalBodyMention(content, participantId) {
203
+ return extractCanonicalMentionIds(content).includes(participantId);
204
+ }
205
+ function hasRelayBodyMention(content, participantId) {
206
+ return (hasCanonicalBodyMention(content, participantId) ||
207
+ extractDispatchMentionIds(content).includes(participantId));
208
+ }
209
+ function encodeStatePathSegment(value) {
210
+ const encoded = Buffer.from(value, 'utf8').toString('hex');
211
+ return encoded.length > 0 ? encoded : 'empty';
212
+ }
213
+ function decodeStatePathSegment(value) {
214
+ if (value === 'empty') {
215
+ return '';
216
+ }
217
+ if (!/^(?:[0-9a-f]{2})+$/u.test(value)) {
218
+ return null;
219
+ }
220
+ return Buffer.from(value, 'hex').toString('utf8');
221
+ }
222
+ function summarizeAnchorContent(content) {
223
+ const collapsed = content.replace(/\s+/gu, ' ').trim();
224
+ if (collapsed.length <= 160) {
225
+ return collapsed;
226
+ }
227
+ return `${collapsed.slice(0, 157)}...`;
228
+ }
229
+ function nextProtocolFallbackDeadline(now = Date.now()) {
230
+ return now + PROTOCOL_PROVIDER_TURN_TIMEOUT_MS;
231
+ }
232
+ function nextProtocolKickoffDecisionDeadline(now = Date.now()) {
233
+ return now + PROTOCOL_KICKOFF_DECISION_TIMEOUT_MS;
234
+ }
235
+ function delay(ms) {
236
+ return new Promise((resolveDelay) => {
237
+ setTimeout(resolveDelay, ms);
238
+ });
239
+ }
240
+ function isReplyTriggeringEventType(eventType) {
241
+ if (eventType == null) {
242
+ return true;
243
+ }
244
+ const normalized = eventType.trim();
245
+ // The SDK forwards lowercase inbound kind literals into ChannelMessageEvent.type.
246
+ return normalized === '' || normalized === 'message' || normalized === 'mention';
247
+ }
248
+ function resolvePublicReplyText(reply) {
249
+ if (reply.controlMalformed) {
250
+ return '';
251
+ }
252
+ if (hasVisibleText(reply.text)) {
253
+ return reply.text;
254
+ }
255
+ return reply.awaitingUser?.question ?? '';
256
+ }
14
257
  function providerUsesDraftProgress(provider) {
15
- return provider === 'claude' || provider === 'copilot';
258
+ return provider === 'claude' || provider === 'codex' || provider === 'copilot';
16
259
  }
17
260
  class DraftMessageController {
18
261
  borgee;
19
262
  channelId;
20
263
  canWrite;
264
+ logger;
265
+ creationMetadata;
21
266
  messageId = null;
22
267
  currentBody = '';
23
268
  pendingBody = null;
24
269
  timer = null;
25
270
  sealed = false;
26
271
  writeChain = Promise.resolve();
27
- constructor(borgee, channelId, canWrite) {
272
+ constructor(borgee, channelId, canWrite, logger, creationMetadata) {
28
273
  this.borgee = borgee;
29
274
  this.channelId = channelId;
30
275
  this.canWrite = canWrite;
276
+ this.logger = logger;
277
+ this.creationMetadata = creationMetadata;
31
278
  }
32
279
  update(text) {
33
280
  if (this.sealed || !hasVisibleText(text)) {
@@ -40,7 +287,7 @@ class DraftMessageController {
40
287
  this.timer = setTimeout(() => {
41
288
  this.timer = null;
42
289
  void this.flushPending().catch((error) => {
43
- console.error('[agents-host] failed to flush draft progress:', error);
290
+ this.logger.error('failed to flush draft progress', error);
44
291
  });
45
292
  }, STREAM_PROGRESS_EDIT_THROTTLE_MS);
46
293
  }
@@ -60,7 +307,7 @@ class DraftMessageController {
60
307
  if (hasVisibleText(finalText)) {
61
308
  this.pendingBody = finalText;
62
309
  await this.flushPending();
63
- return;
310
+ return this.messageId;
64
311
  }
65
312
  await this.enqueueWrite(async () => {
66
313
  if (!this.canWrite() || !this.messageId) {
@@ -71,6 +318,7 @@ class DraftMessageController {
71
318
  this.currentBody = '';
72
319
  this.pendingBody = null;
73
320
  });
321
+ return null;
74
322
  }
75
323
  async discard() {
76
324
  this.sealed = true;
@@ -80,7 +328,7 @@ class DraftMessageController {
80
328
  }
81
329
  this.pendingBody = null;
82
330
  await this.enqueueWrite(async () => {
83
- if (!this.canWrite() || !this.messageId) {
331
+ if (!this.messageId) {
84
332
  return;
85
333
  }
86
334
  await this.borgee.deleteMessage(this.messageId);
@@ -98,7 +346,11 @@ class DraftMessageController {
98
346
  await this.borgee.editMessage(this.messageId, body);
99
347
  }
100
348
  else {
101
- const posted = await this.borgee.postMessage(this.channelId, body);
349
+ const posted = await this.borgee.postMessage({
350
+ channelId: this.channelId,
351
+ body,
352
+ replyToId: this.creationMetadata?.replyToId,
353
+ });
102
354
  this.messageId = posted.messageId;
103
355
  }
104
356
  this.currentBody = body;
@@ -109,201 +361,2520 @@ class DraftMessageController {
109
361
  return this.writeChain;
110
362
  }
111
363
  }
364
+ const BLOCKED_AUTHOR_REFRESH_BACKOFF_MS = 1_000;
365
+ const MAX_QUEUED_MESSAGES_PER_CHANNEL = 8;
366
+ const COLLABORATION_SENDS_PER_TURN = 1;
112
367
  /**
113
368
  * Minimal single-agent agents host: connects one local Claude/Copilot CLI
114
369
  * to exactly one Borgee agent over `@borgee/plugin-sdk` (BPP / `/ws/plugin`).
115
- *
116
- * Conversation memory is handled entirely by each provider's native
117
- * per-channel CLI session (Claude `--resume`, Copilot `--session-id`) — see
118
- * the cli-client.ts file under each provider's folder. This class does not
119
- * keep any message history itself.
120
- *
121
- * Out of scope for this single-agent runner (see README): execution/remote-command
122
- * dispatch, node provisioning, systemd install, and scheduled (periodic)
123
- * prompts. Multi-agent local hosting is handled one level up by the local-config
124
- * supervisor, which creates one isolated `AgentsHost` per effective agent key.
125
- *
126
- * Message gating (mention-only / DM rules) is NOT done here: the server
127
- * enforces per-agent + per-channel `require_mention` at BPP fan-out, so this
128
- * host simply replies to every message it is handed (minus its own).
129
370
  */
130
371
  export class AgentsHost {
131
372
  config;
373
+ runtime;
132
374
  provider;
133
375
  borgee;
376
+ logger;
377
+ contextInjectionEnabled;
378
+ collaborationEnabled;
379
+ collaborationOutcomeModelEnabled;
380
+ attentionFollowSemanticsEnabled;
381
+ taskThreadCollaborationContractEnabled;
382
+ collaborationCapabilitiesDiagnosticsEnabled;
134
383
  activeTurns = new Set();
384
+ awaitingUserByChannel = new Map();
385
+ collaborationOutcomeByChannel = new Map();
386
+ attentionSnapshotByChannel = new Map();
387
+ missedCollaborationDiagnosticByChannel = new Map();
135
388
  progressChannelQueues = new Map();
136
389
  progressDrafts = new Map();
390
+ collaborationDrafts = new Map();
391
+ collaborationChannels = new Map();
392
+ participantDirectoryByUserId = new Map();
393
+ participantKindsByUserId = new Map();
394
+ participantRefreshBackoffByUserId = new Map();
395
+ participantRefreshPromise = null;
396
+ participantDirectorySupported = true;
137
397
  selfAgentId = null;
398
+ selfAgentProfile = null;
138
399
  selfAgentIdPromise = null;
139
400
  started = false;
140
401
  controlPlaneClosed = false;
141
402
  constructor(config, deps) {
142
403
  this.config = config;
143
- this.provider = deps?.provider ?? createProvider({
144
- provider: config.agent.provider,
145
- stateRootDir: config.stateRootDir,
404
+ this.logger = new HostLogger(deps?.runtimeOptions);
405
+ const compatibilityGates = resolveInternalCompatibilityGates();
406
+ const contextInjectionGateEnabled = compatibilityGates.has(CONTEXT_INJECTION_COMPATIBILITY_GATE);
407
+ this.contextInjectionEnabled = contextInjectionGateEnabled;
408
+ const skillRuntimeGateEnabled = compatibilityGates.has(SKILL_RUNTIME_COMPATIBILITY_GATE);
409
+ const localhostGatewayGateEnabled = contextInjectionGateEnabled &&
410
+ skillRuntimeGateEnabled &&
411
+ compatibilityGates.has(LOCALHOST_GATEWAY_COMPATIBILITY_GATE);
412
+ const policyAuditGateEnabled = compatibilityGates.has(POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE);
413
+ this.collaborationEnabled =
414
+ localhostGatewayGateEnabled &&
415
+ compatibilityGates.has(COLLABORATION_SKILL_FIRST_COMPATIBILITY_GATE) &&
416
+ policyAuditGateEnabled &&
417
+ resolveInternalPolicyMode(policyAuditGateEnabled) === 'enforce';
418
+ this.collaborationOutcomeModelEnabled = compatibilityGates.has(COLLABORATION_OUTCOME_MODEL_COMPATIBILITY_GATE);
419
+ this.attentionFollowSemanticsEnabled = compatibilityGates.has(ATTENTION_FOLLOW_SEMANTICS_COMPATIBILITY_GATE);
420
+ this.taskThreadCollaborationContractEnabled =
421
+ contextInjectionGateEnabled
422
+ && compatibilityGates.has(TASK_THREAD_COLLABORATION_CONTRACT_COMPATIBILITY_GATE);
423
+ this.collaborationCapabilitiesDiagnosticsEnabled =
424
+ contextInjectionGateEnabled
425
+ && compatibilityGates.has(COLLABORATION_CAPABILITIES_DIAGNOSTICS_COMPATIBILITY_GATE);
426
+ this.runtime = createDefaultHostRuntime(config, {
427
+ logger: this.logger,
428
+ provider: deps?.provider,
429
+ borgee: deps?.borgee,
146
430
  resolveStableAgentId: () => this.selfAgentId ?? undefined,
147
- claudeCommand: config.claudeCommand,
148
- claudeArgs: config.claudeArgs,
149
- copilotCommand: config.copilotCommand,
150
- copilotArgs: config.copilotArgs,
151
- copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
152
- });
153
- this.borgee = deps?.borgee ?? new SdkChatControlPlane(config.borgeeBaseUrl, config.agent.agentApiKey, undefined, {
154
- cursorStore: createDurableCursorStore({
155
- stateRootDir: config.stateRootDir,
156
- }),
157
- // Lets the server know which runtime/provider actually connected
158
- // (internal/bpp ConnectHandler → users.last_connected_plugin_id), so
159
- // the web UI can show real state instead of a client-side guess.
160
- pluginId: `agents-host:${config.agent.provider}`,
431
+ readCollaborationDraft: (input) => this.readCollaborationDraft(input),
432
+ authorizeCollaborationSend: (input) => this.authorizeCollaborationSend(input),
161
433
  });
434
+ this.provider = this.runtime.provider;
435
+ this.borgee = this.runtime.controlPlane;
162
436
  }
163
437
  async start() {
164
438
  if (this.started)
165
439
  return;
166
440
  this.started = true;
167
441
  this.controlPlaneClosed = false;
168
- await ensurePrivateStateRoot(this.config.stateRootDir);
169
- await this.borgee.connect((message) => {
170
- if (!this.started) {
171
- return;
172
- }
173
- const task = providerUsesDraftProgress(this.config.agent.provider)
174
- ? this.enqueueProgressChannelTurn(message.channel_id, () => this.handleMessage(message))
175
- : this.handleMessage(message);
176
- this.trackActiveTurn(task);
177
- });
178
- const agentId = await this.ensureSelfAgentId();
179
- console.log('[agents-host] connected', {
180
- agentId,
181
- agentName: this.config.agent.agentName,
442
+ this.logger.debug('starting host', {
182
443
  provider: this.config.agent.provider,
444
+ agentName: this.config.agent.agentName,
445
+ stateRootDir: this.config.stateRootDir,
446
+ collaborationEnabled: this.collaborationEnabled,
183
447
  });
448
+ const bufferedMessages = [];
449
+ let bufferingMessages = true;
450
+ try {
451
+ await this.runtime.start((message) => {
452
+ if (!this.started) {
453
+ return;
454
+ }
455
+ if (bufferingMessages) {
456
+ bufferedMessages.push(message);
457
+ return;
458
+ }
459
+ this.dispatchMessage(message);
460
+ });
461
+ const agentId = await this.ensureSelfAgentId();
462
+ bufferingMessages = false;
463
+ while (bufferedMessages.length > 0) {
464
+ const pendingMessages = bufferedMessages.splice(0, bufferedMessages.length);
465
+ for (const message of pendingMessages) {
466
+ this.dispatchMessage(message);
467
+ }
468
+ }
469
+ this.logger.debug('connected host', {
470
+ agentId,
471
+ agentName: this.config.agent.agentName,
472
+ provider: this.config.agent.provider,
473
+ });
474
+ }
475
+ catch (error) {
476
+ bufferingMessages = false;
477
+ bufferedMessages.length = 0;
478
+ this.started = false;
479
+ this.controlPlaneClosed = true;
480
+ await this.runtime.stop().catch(() => { });
481
+ throw error;
482
+ }
184
483
  }
185
484
  async stop() {
186
485
  if (!this.started)
187
486
  return;
188
487
  this.started = false;
488
+ this.logger.debug('stopping host', {
489
+ activeTurnCount: this.activeTurns.size,
490
+ provider: this.config.agent.provider,
491
+ agentName: this.config.agent.agentName,
492
+ });
493
+ for (const channelId of this.collaborationChannels.keys()) {
494
+ this.invalidateChannelTurn(channelId);
495
+ this.clearProtocolFallback(this.collaborationChannels.get(channelId));
496
+ }
189
497
  for (const draft of this.progressDrafts.values()) {
190
498
  draft.cancelProgress();
191
499
  }
500
+ for (const channelId of [...this.collaborationDrafts.keys()]) {
501
+ this.clearCollaborationDraft(channelId);
502
+ }
503
+ this.awaitingUserByChannel.clear();
504
+ this.collaborationOutcomeByChannel.clear();
505
+ this.attentionSnapshotByChannel.clear();
506
+ this.missedCollaborationDiagnosticByChannel.clear();
192
507
  this.controlPlaneClosed = true;
193
- await this.borgee.close();
194
- await this.provider.dispose?.();
508
+ await this.runtime.stop();
195
509
  await Promise.allSettled([...this.activeTurns]);
196
510
  }
197
- async handleMessage(msg) {
198
- console.log('[agents-host] received message', {
199
- eventType: msg.type,
200
- provider: this.config.agent.provider,
201
- agentName: this.config.agent.agentName,
202
- channelId: msg.channel_id,
511
+ trackActiveTurn(task) {
512
+ this.activeTurns.add(task);
513
+ void task.finally(() => {
514
+ this.activeTurns.delete(task);
203
515
  });
204
- const selfAgentId = await this.ensureSelfAgentId();
205
- const authorId = String(msg.user_id ?? msg.sender_id ?? 'unknown');
206
- if (authorId === selfAgentId) {
516
+ }
517
+ dispatchMessage(message) {
518
+ const task = this.collaborationEnabled
519
+ ? this.handleCollaborationMessage(message)
520
+ : providerUsesDraftProgress(this.config.agent.provider)
521
+ ? this.enqueueProgressChannelTurn(message.channel_id, () => this.handleLegacyMessage(message))
522
+ : this.handleLegacyMessage(message);
523
+ this.trackActiveTurn(task);
524
+ }
525
+ async handleLegacyMessage(msg) {
526
+ const normalized = await this.normalizeMessage(msg);
527
+ if (!normalized) {
207
528
  return;
208
529
  }
209
- const content = String(msg.content ?? msg.body ?? '').trim();
210
- if (!content)
211
- return;
212
- // No client-side mention/DM gating here: the server already decides which
213
- // messages an agent receives (per-agent + per-channel require_mention is
214
- // enforced at BPP fan-out via AgentReceivesChannelMessage). If a channel
215
- // message reaches this host at all, the agent is meant to handle it.
216
- const stopTyping = this.borgee.startTyping(msg.channel_id);
217
- const useDraftProgress = providerUsesDraftProgress(this.config.agent.provider);
218
- try {
219
- const reply = await this.provider.generateReply({
220
- agentName: this.config.agent.agentName,
221
- provider: this.config.agent.provider,
222
- channelId: msg.channel_id,
223
- incomingAuthorId: authorId,
224
- incomingContent: content,
225
- }, useDraftProgress
226
- ? {
227
- onProgress: (update) => {
228
- if (!this.started || this.controlPlaneClosed) {
229
- return;
230
- }
231
- this.ensureProgressDraft(msg.channel_id).update(update.text);
232
- },
530
+ let taskThreadContext = { state: 'inactive' };
531
+ let attentionSnapshot;
532
+ if (this.attentionFollowSemanticsEnabled
533
+ || this.taskThreadCollaborationContractEnabled) {
534
+ taskThreadContext = await this.buildTurnTaskThreadContext(msg, msg.channel_id);
535
+ if (this.attentionFollowSemanticsEnabled) {
536
+ const channelState = this.ensureCollaborationChannelState(msg.channel_id);
537
+ const authorKind = await this.classifyAuthor(normalized.authorId, false);
538
+ await this.ensureAttentionSnapshotLoaded(msg.channel_id, channelState);
539
+ attentionSnapshot = this.getAttentionSnapshotForTurn(msg.channel_id, taskThreadContext);
540
+ if (this.isHumanWakeFiltered(msg, authorKind, attentionSnapshot)) {
541
+ return;
233
542
  }
234
- : undefined);
235
- if (useDraftProgress) {
236
- const draft = this.progressDrafts.get(msg.channel_id);
237
- if (!this.started) {
238
- await draft?.discard().catch(() => { });
239
- this.progressDrafts.delete(msg.channel_id);
543
+ }
544
+ }
545
+ if (msg.message_type === 'task_assignment') {
546
+ await this.markAssignedTaskInProgress(msg.channel_id, normalized.content);
547
+ }
548
+ await this.runUngatedTurn(msg, normalized.authorId, normalized.content, taskThreadContext, attentionSnapshot);
549
+ }
550
+ async handleCollaborationMessage(msg) {
551
+ const normalized = await this.normalizeMessage(msg);
552
+ if (!normalized) {
553
+ return;
554
+ }
555
+ if (msg.message_type === 'task_assignment') {
556
+ await this.markAssignedTaskInProgress(msg.channel_id, normalized.content);
557
+ }
558
+ const channelState = this.ensureCollaborationChannelState(msg.channel_id);
559
+ await this.ensureProtocolStateLoaded(msg.channel_id, channelState);
560
+ await this.ensureAttentionSnapshotLoaded(msg.channel_id, channelState);
561
+ const selfAgentId = await this.ensureSelfAgentId();
562
+ const authorKind = await this.classifyAuthor(normalized.authorId, false);
563
+ const taskThreadContext = await this.buildTurnTaskThreadContext(msg, msg.channel_id);
564
+ const attentionSnapshot = this.getAttentionSnapshotForTurn(msg.channel_id, taskThreadContext);
565
+ if (channelState.protocol?.active) {
566
+ if (msg.message_id && authorKind === 'agent') {
567
+ await this.handleActiveProtocolParticipantMessage(msg, normalized, channelState, channelState.protocol, selfAgentId);
568
+ if (channelState.protocol?.active ||
569
+ channelState.queue.some((message) => message.kind === 'protocol-turn')) {
570
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
240
571
  return;
241
572
  }
242
- await (draft ?? this.ensureProgressDraft(msg.channel_id)).finalize(reply.text);
243
- this.progressDrafts.delete(msg.channel_id);
244
573
  }
245
- else if (this.started && !this.controlPlaneClosed) {
246
- await this.borgee.postMessage(msg.channel_id, reply.text);
574
+ if (authorKind !== 'agent') {
575
+ if (msg.message_id === channelState.protocol.anchorMessageId) {
576
+ await this.handleActiveProtocolAnchorReplay(msg, normalized, channelState, channelState.protocol, selfAgentId);
577
+ return;
578
+ }
579
+ this.cancelProtocolState(msg.channel_id, channelState);
247
580
  }
248
581
  }
249
- catch (error) {
250
- if (useDraftProgress) {
251
- const draft = this.progressDrafts.get(msg.channel_id);
252
- if (draft) {
253
- await draft.discard().catch(() => { });
254
- this.progressDrafts.delete(msg.channel_id);
582
+ if (channelState.blocked) {
583
+ const blockedAuthorKind = await this.classifyBlockedWakeAuthor(normalized.authorId, channelState.blocked);
584
+ if (blockedAuthorKind === 'agent') {
585
+ channelState.blocked.suppressedAgentMessages += 1;
586
+ this.recordBlockedSuppressionOutcome(msg.channel_id, channelState);
587
+ return;
588
+ }
589
+ if (blockedAuthorKind === 'unknown') {
590
+ this.enqueueCollaborationMessage(channelState, {
591
+ raw: msg,
592
+ authorId: normalized.authorId,
593
+ authorKind: 'unknown',
594
+ content: normalized.content,
595
+ attentionSnapshot,
596
+ taskThreadContext,
597
+ });
598
+ const resumed = await this.tryResumeBlockedFromQueue(msg.channel_id, channelState);
599
+ if (resumed) {
600
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
255
601
  }
602
+ return;
256
603
  }
257
- console.error('[agents-host] failed to generate or send reply:', {
258
- provider: this.config.agent.provider,
259
- agentName: this.config.agent.agentName,
260
- error,
261
- });
604
+ const wakeBlockedState = channelState.blocked.awaitingUser;
605
+ channelState.blocked = undefined;
606
+ this.awaitingUserByChannel.delete(msg.channel_id);
607
+ channelState.queue = [
608
+ {
609
+ raw: msg,
610
+ authorId: normalized.authorId,
611
+ authorKind: 'human',
612
+ content: normalized.content,
613
+ attentionSnapshot,
614
+ taskThreadContext,
615
+ wakeBlockedState,
616
+ },
617
+ ];
618
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
619
+ return;
262
620
  }
263
- finally {
264
- stopTyping();
621
+ if (authorKind !== 'agent') {
622
+ const protocolCandidate = this.detectProtocolKickoffCandidate(msg.message_id, normalized.content, selfAgentId);
623
+ if (protocolCandidate) {
624
+ const protocolRequest = await this.resolveProtocolKickoffRequest(msg, normalized, protocolCandidate, selfAgentId);
625
+ if (protocolRequest) {
626
+ await this.startProtocolForAnchor(msg, normalized, channelState, protocolRequest, selfAgentId);
627
+ return;
628
+ }
629
+ }
265
630
  }
266
- }
267
- trackActiveTurn(task) {
268
- this.activeTurns.add(task);
269
- void task.finally(() => {
270
- this.activeTurns.delete(task);
631
+ if (this.isHumanWakeFiltered(msg, authorKind, attentionSnapshot)) {
632
+ return;
633
+ }
634
+ if (authorKind !== 'agent' && channelState.activeTurn) {
635
+ this.supersedeActiveTurn(msg.channel_id, channelState.activeTurn);
636
+ }
637
+ if (authorKind !== 'agent') {
638
+ channelState.recentTurn = undefined;
639
+ }
640
+ this.enqueueCollaborationMessage(channelState, {
641
+ raw: msg,
642
+ authorId: normalized.authorId,
643
+ authorKind,
644
+ content: normalized.content,
645
+ attentionSnapshot,
646
+ taskThreadContext,
271
647
  });
648
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
272
649
  }
273
- enqueueProgressChannelTurn(channelId, task) {
274
- const previous = this.progressChannelQueues.get(channelId) ?? Promise.resolve();
275
- const next = previous
276
- .catch(() => { })
277
- .then(task)
278
- .finally(() => {
279
- if (this.progressChannelQueues.get(channelId) === next) {
280
- this.progressChannelQueues.delete(channelId);
650
+ detectProtocolKickoffCandidate(anchorMessageId, content, selfAgentId) {
651
+ if (!anchorMessageId) {
652
+ return null;
653
+ }
654
+ const mentions = extractCanonicalMentionIds(content);
655
+ if (mentions.length !== 2) {
656
+ return null;
657
+ }
658
+ const unique = [...new Set(mentions)].sort();
659
+ if (unique.length !== 2 || !unique.includes(selfAgentId)) {
660
+ return null;
661
+ }
662
+ return {
663
+ anchorMessageId,
664
+ participantIds: [unique[0], unique[1]],
665
+ issuerId: unique[0],
666
+ };
667
+ }
668
+ async validateProtocolKickoffCandidate(anchorAuthorId, protocolCandidate, selfAgentId) {
669
+ const anchorAuthorKind = await this.classifyAuthor(anchorAuthorId, true);
670
+ if (anchorAuthorKind === 'agent') {
671
+ return null;
672
+ }
673
+ if (protocolCandidate.participantIds.includes(anchorAuthorId)) {
674
+ return null;
675
+ }
676
+ for (const participantId of protocolCandidate.participantIds) {
677
+ if (participantId === selfAgentId) {
678
+ continue;
281
679
  }
680
+ const participantKind = await this.classifyAuthor(participantId, true);
681
+ if (participantKind === 'human') {
682
+ return null;
683
+ }
684
+ }
685
+ return protocolCandidate;
686
+ }
687
+ async resolveProtocolKickoffRequest(msg, normalized, protocolCandidate, selfAgentId) {
688
+ const validatedCandidate = await this.validateProtocolKickoffCandidate(normalized.authorId, protocolCandidate, selfAgentId);
689
+ if (!validatedCandidate) {
690
+ return null;
691
+ }
692
+ const pendingDecision = await this.ensureProtocolKickoffDecisionRecord(msg.channel_id, validatedCandidate);
693
+ if (!pendingDecision) {
694
+ return null;
695
+ }
696
+ const decision = pendingDecision.status === 'pending'
697
+ ? validatedCandidate.issuerId === selfAgentId
698
+ ? await this.evaluateIssuerProtocolKickoff(msg, normalized, validatedCandidate, pendingDecision)
699
+ : await this.waitForProtocolKickoffDecision(msg.channel_id, validatedCandidate, pendingDecision.deadlineAt)
700
+ : pendingDecision;
701
+ if (!decision || decision.status !== 'start') {
702
+ return null;
703
+ }
704
+ const rounds = this.validateProtocolKickoffRounds(decision.rounds);
705
+ if (!rounds) {
706
+ return null;
707
+ }
708
+ return {
709
+ participantIds: decision.participantIds,
710
+ issuerId: decision.issuerId,
711
+ maxTurnCount: rounds * 2,
712
+ };
713
+ }
714
+ validateProtocolKickoffRounds(rounds) {
715
+ if (!Number.isFinite(rounds) ||
716
+ typeof rounds !== 'number' ||
717
+ !Number.isInteger(rounds) ||
718
+ rounds < 1) {
719
+ return null;
720
+ }
721
+ return Math.min(rounds, PROTOCOL_KICKOFF_ROUND_LIMIT);
722
+ }
723
+ protocolRequestFromKickoffDecision(decision) {
724
+ const rounds = this.validateProtocolKickoffRounds(decision.rounds);
725
+ if (!rounds) {
726
+ return null;
727
+ }
728
+ return {
729
+ participantIds: decision.participantIds,
730
+ issuerId: decision.issuerId,
731
+ maxTurnCount: rounds * 2,
732
+ };
733
+ }
734
+ async evaluateIssuerProtocolKickoff(msg, normalized, protocolCandidate, pendingDecision) {
735
+ const priorMissedCollaborationDiagnostic = this.resolveMissedCollaborationDiagnosticForTurn(msg.channel_id);
736
+ const executed = await this.executeTurn(msg, normalized.authorId, normalized.content, {
737
+ kickoff: protocolCandidate,
738
+ silent: true,
282
739
  });
283
- this.progressChannelQueues.set(channelId, next);
284
- return next;
740
+ if (executed.stale) {
741
+ return null;
742
+ }
743
+ this.recordCollaborationOutcome(msg.channel_id, {
744
+ responseState: executed.completed ? 'responded' : 'failed',
745
+ deliveryState: executed.deliveryState ?? 'not-delivered',
746
+ });
747
+ this.updateMissedCollaborationDiagnostic(msg.channel_id, priorMissedCollaborationDiagnostic);
748
+ const reply = executed.reply;
749
+ const rounds = reply?.control?.kind === 'start-protocol'
750
+ ? this.validateProtocolKickoffRounds(reply.control.rounds)
751
+ : null;
752
+ const finalizedDecision = await this.finalizeProtocolKickoffDecision(msg.channel_id, protocolCandidate, pendingDecision.deadlineAt, {
753
+ status: rounds ? 'start' : 'decline',
754
+ ...(rounds ? { rounds } : {}),
755
+ });
756
+ this.logger.debug('completed silent kickoff evaluation', {
757
+ channelId: msg.channel_id,
758
+ messageId: msg.message_id,
759
+ anchorMessageId: protocolCandidate.anchorMessageId,
760
+ provider: this.config.agent.provider,
761
+ agentName: this.config.agent.agentName,
762
+ controlKind: reply?.control?.kind ?? null,
763
+ controlMalformed: reply?.controlMalformed ?? false,
764
+ outcome: rounds ? 'start' : reply?.controlMalformed ? 'malformed-control' : 'no-control',
765
+ persistedStatus: finalizedDecision?.status ?? null,
766
+ persistedRounds: finalizedDecision?.rounds ?? null,
767
+ replyLength: reply ? resolvePublicReplyText(reply).length : 0,
768
+ });
769
+ return finalizedDecision;
285
770
  }
286
- ensureProgressDraft(channelId) {
287
- let draft = this.progressDrafts.get(channelId);
288
- if (!draft) {
289
- draft = new DraftMessageController(this.borgee, channelId, () => !this.controlPlaneClosed);
290
- this.progressDrafts.set(channelId, draft);
771
+ async waitForProtocolKickoffDecision(channelId, protocolCandidate, deadlineAt) {
772
+ for (;;) {
773
+ const decision = await this.readProtocolKickoffDecisionRecord(channelId, protocolCandidate.anchorMessageId);
774
+ if (decision &&
775
+ this.matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, decision)) {
776
+ if (decision.status !== 'pending') {
777
+ return decision;
778
+ }
779
+ const now = Date.now();
780
+ if (decision.deadlineAt <= now) {
781
+ return null;
782
+ }
783
+ await delay(Math.min(PROTOCOL_KICKOFF_DECISION_POLL_INTERVAL_MS, decision.deadlineAt - now));
784
+ continue;
785
+ }
786
+ const now = Date.now();
787
+ if (deadlineAt <= now) {
788
+ return null;
789
+ }
790
+ await delay(Math.min(PROTOCOL_KICKOFF_DECISION_POLL_INTERVAL_MS, deadlineAt - now));
291
791
  }
292
- return draft;
293
792
  }
294
- async ensureSelfAgentId() {
295
- if (this.selfAgentId) {
296
- return this.selfAgentId;
793
+ async ensureProtocolKickoffDecisionRecord(channelId, protocolCandidate) {
794
+ const existing = await this.readProtocolKickoffDecisionRecord(channelId, protocolCandidate.anchorMessageId);
795
+ if (existing) {
796
+ return this.matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, existing)
797
+ ? existing
798
+ : null;
297
799
  }
298
- if (!this.selfAgentIdPromise) {
299
- this.selfAgentIdPromise = this.borgee.getMe().then((me) => {
300
- this.selfAgentId = me.id;
301
- return me.id;
302
- }).catch((error) => {
303
- this.selfAgentIdPromise = null;
800
+ const record = {
801
+ channelId,
802
+ ...protocolCandidate,
803
+ deadlineAt: nextProtocolKickoffDecisionDeadline(),
804
+ status: 'pending',
805
+ };
806
+ try {
807
+ await this.writeProtocolKickoffDecisionRecord(record, 'wx');
808
+ return record;
809
+ }
810
+ catch (error) {
811
+ const code = error.code;
812
+ if (code !== 'EEXIST') {
304
813
  throw error;
814
+ }
815
+ }
816
+ const persisted = await this.readProtocolKickoffDecisionRecord(channelId, protocolCandidate.anchorMessageId);
817
+ return persisted &&
818
+ this.matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, persisted)
819
+ ? persisted
820
+ : null;
821
+ }
822
+ async finalizeProtocolKickoffDecision(channelId, protocolCandidate, deadlineAt, decision) {
823
+ const current = await this.readProtocolKickoffDecisionRecord(channelId, protocolCandidate.anchorMessageId);
824
+ if (current &&
825
+ !this.matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, current)) {
826
+ return null;
827
+ }
828
+ const effectiveDeadlineAt = current?.deadlineAt ?? deadlineAt;
829
+ if (effectiveDeadlineAt <= Date.now()) {
830
+ if (!current || current.status === 'pending') {
831
+ await this.writeProtocolKickoffDecisionRecord({
832
+ channelId,
833
+ ...protocolCandidate,
834
+ deadlineAt: effectiveDeadlineAt,
835
+ status: 'decline',
836
+ decidedAt: Date.now(),
837
+ });
838
+ }
839
+ return null;
840
+ }
841
+ if (current?.status === 'start') {
842
+ return current;
843
+ }
844
+ if (current?.status === 'decline') {
845
+ return current;
846
+ }
847
+ const finalized = {
848
+ channelId,
849
+ ...protocolCandidate,
850
+ deadlineAt: effectiveDeadlineAt,
851
+ status: decision.status,
852
+ ...(decision.rounds ? { rounds: decision.rounds } : {}),
853
+ decidedAt: Date.now(),
854
+ };
855
+ await this.writeProtocolKickoffDecisionRecord(finalized);
856
+ return finalized;
857
+ }
858
+ matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, record) {
859
+ return (record.channelId === channelId &&
860
+ record.anchorMessageId === protocolCandidate.anchorMessageId &&
861
+ record.issuerId === protocolCandidate.issuerId &&
862
+ record.participantIds[0] === protocolCandidate.participantIds[0] &&
863
+ record.participantIds[1] === protocolCandidate.participantIds[1]);
864
+ }
865
+ resolveProtocolKickoffDecisionPath(channelId, anchorMessageId) {
866
+ return resolveSharedProtocolKickoffDecisionPath(this.config.stateRootDir, channelId, anchorMessageId);
867
+ }
868
+ resolveSharedProtocolStatusPath(channelId, anchorMessageId) {
869
+ return resolveSharedProtocolStatusPath(this.config.stateRootDir, channelId, anchorMessageId);
870
+ }
871
+ async readProtocolKickoffDecisionRecord(channelId, anchorMessageId) {
872
+ try {
873
+ const content = await readFile(this.resolveProtocolKickoffDecisionPath(channelId, anchorMessageId), 'utf8');
874
+ return JSON.parse(content);
875
+ }
876
+ catch (error) {
877
+ const code = error.code;
878
+ if (code === 'ENOENT') {
879
+ return null;
880
+ }
881
+ throw error;
882
+ }
883
+ }
884
+ async writeProtocolKickoffDecisionRecord(record, flag = 'w') {
885
+ const path = this.resolveProtocolKickoffDecisionPath(record.channelId, record.anchorMessageId);
886
+ await writePrivateJsonFile(path, record, { exclusive: flag === 'wx' });
887
+ }
888
+ async handleActiveProtocolAnchorReplay(msg, normalized, channelState, protocolState, selfAgentId) {
889
+ if (protocolState.currentTurnIndex > 0) {
890
+ return;
891
+ }
892
+ if (selfAgentId === protocolState.issuerId) {
893
+ this.enqueueCollaborationMessage(channelState, {
894
+ kind: 'protocol-turn',
895
+ raw: msg,
896
+ authorId: normalized.authorId,
897
+ authorKind: 'human',
898
+ content: normalized.content,
305
899
  });
900
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
901
+ return;
306
902
  }
307
- return this.selfAgentIdPromise;
903
+ this.armProtocolFallback(msg.channel_id, channelState, protocolState);
904
+ }
905
+ async startProtocolForAnchor(msg, normalized, channelState, protocolRequest, selfAgentId) {
906
+ this.cancelProtocolState(msg.channel_id, channelState);
907
+ const protocolState = {
908
+ ownerAgentId: selfAgentId,
909
+ anchorAuthorId: normalized.authorId,
910
+ anchorContent: normalized.content,
911
+ anchorMessageId: msg.message_id ?? randomUUID(),
912
+ participantIds: protocolRequest.participantIds,
913
+ issuerId: protocolRequest.issuerId,
914
+ expectedNextSpeakerId: protocolRequest.issuerId,
915
+ currentTurnIndex: 0,
916
+ maxTurnCount: protocolRequest.maxTurnCount,
917
+ active: true,
918
+ finished: false,
919
+ fallbackDeadlineAt: nextProtocolFallbackDeadline(),
920
+ fallbackTimer: null,
921
+ };
922
+ channelState.protocol = protocolState;
923
+ channelState.protocolLoaded = true;
924
+ await this.persistProtocolState(msg.channel_id, protocolState);
925
+ await this.persistSharedProtocolStatus(msg.channel_id, protocolState);
926
+ if (selfAgentId === protocolRequest.issuerId) {
927
+ this.enqueueCollaborationMessage(channelState, {
928
+ kind: 'protocol-turn',
929
+ raw: msg,
930
+ authorId: normalized.authorId,
931
+ authorKind: 'human',
932
+ content: normalized.content,
933
+ });
934
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
935
+ return;
936
+ }
937
+ this.armProtocolFallback(msg.channel_id, channelState, protocolState);
938
+ }
939
+ async deliverDeferredTurnReply(channelId, activeTurn, executed) {
940
+ if (!executed.completed ||
941
+ executed.stale ||
942
+ !this.canAcceptTurnOutput(channelId, activeTurn.generation)) {
943
+ return;
944
+ }
945
+ const publicReplyText = executed.publicReplyText ?? '';
946
+ if (!this.started || this.controlPlaneClosed || !hasVisibleText(publicReplyText)) {
947
+ return;
948
+ }
949
+ const posted = await this.borgee.postMessage({
950
+ channelId,
951
+ body: executed.visibleReplyBody ?? publicReplyText,
952
+ replyToId: activeTurn.delivery?.replyToId,
953
+ });
954
+ executed.deliveredMessageId = posted.messageId;
955
+ }
956
+ async handleActiveProtocolParticipantMessage(msg, normalized, channelState, protocolState, selfAgentId) {
957
+ if (!msg.message_id || normalized.authorId !== protocolState.expectedNextSpeakerId) {
958
+ return;
959
+ }
960
+ const expectedReplyToId = protocolState.lastProtocolMessageId ?? protocolState.anchorMessageId;
961
+ const confirmed = await this.confirmProtocolMessage(msg.channel_id, msg.message_id, normalized.authorId, expectedReplyToId, msg.created_at);
962
+ if (!confirmed) {
963
+ if (selfAgentId !== protocolState.issuerId) {
964
+ this.armProtocolFallback(msg.channel_id, channelState, protocolState);
965
+ }
966
+ return;
967
+ }
968
+ this.clearProtocolFallback(channelState);
969
+ protocolState.currentTurnIndex += 1;
970
+ protocolState.lastProtocolMessageId = msg.message_id;
971
+ const continuesProtocol = msg.type === 'mention' || hasRelayBodyMention(confirmed.body, selfAgentId);
972
+ if (!continuesProtocol) {
973
+ protocolState.active = false;
974
+ protocolState.finished = true;
975
+ protocolState.expectedNextSpeakerId = normalized.authorId;
976
+ await this.persistProtocolState(msg.channel_id, protocolState);
977
+ return;
978
+ }
979
+ if (protocolState.currentTurnIndex >= protocolState.maxTurnCount) {
980
+ protocolState.active = false;
981
+ protocolState.finished = true;
982
+ protocolState.expectedNextSpeakerId = normalized.authorId;
983
+ await this.persistProtocolState(msg.channel_id, protocolState);
984
+ return;
985
+ }
986
+ protocolState.expectedNextSpeakerId = this.otherProtocolParticipant(protocolState, normalized.authorId);
987
+ protocolState.fallbackDeadlineAt = nextProtocolFallbackDeadline();
988
+ await this.persistProtocolState(msg.channel_id, protocolState);
989
+ if (protocolState.expectedNextSpeakerId !== selfAgentId) {
990
+ if (selfAgentId !== protocolState.issuerId) {
991
+ this.armProtocolFallback(msg.channel_id, channelState, protocolState);
992
+ }
993
+ return;
994
+ }
995
+ this.enqueueCollaborationMessage(channelState, {
996
+ kind: 'protocol-turn',
997
+ raw: msg,
998
+ authorId: normalized.authorId,
999
+ authorKind: 'agent',
1000
+ content: normalized.content,
1001
+ });
1002
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
1003
+ }
1004
+ enqueueCollaborationMessage(channelState, message) {
1005
+ channelState.queue.push(message);
1006
+ while (channelState.queue.length > MAX_QUEUED_MESSAGES_PER_CHANNEL) {
1007
+ channelState.queue.shift();
1008
+ }
1009
+ }
1010
+ ensureCollaborationChannelState(channelId) {
1011
+ let state = this.collaborationChannels.get(channelId);
1012
+ if (!state) {
1013
+ state = {
1014
+ generation: 0,
1015
+ queue: [],
1016
+ running: false,
1017
+ protocolLoaded: false,
1018
+ attentionLoaded: false,
1019
+ };
1020
+ this.collaborationChannels.set(channelId, state);
1021
+ }
1022
+ return state;
1023
+ }
1024
+ async ensureCollaborationProcessor(channelId, channelState) {
1025
+ if (channelState.processor) {
1026
+ await channelState.processor;
1027
+ return;
1028
+ }
1029
+ const processor = this.processCollaborationQueue(channelId, channelState).finally(() => {
1030
+ if (channelState.processor === processor) {
1031
+ channelState.processor = undefined;
1032
+ }
1033
+ });
1034
+ channelState.processor = processor;
1035
+ await processor;
1036
+ }
1037
+ async processCollaborationQueue(channelId, channelState) {
1038
+ while (this.started && !this.controlPlaneClosed && !channelState.blocked) {
1039
+ const next = channelState.queue.shift();
1040
+ if (!next) {
1041
+ return;
1042
+ }
1043
+ channelState.running = true;
1044
+ try {
1045
+ await this.runCollaborationTurn(channelId, channelState, next);
1046
+ }
1047
+ finally {
1048
+ channelState.running = false;
1049
+ }
1050
+ if (channelState.blocked) {
1051
+ channelState.queue = channelState.queue.filter((queued) => queued.authorKind !== 'agent');
1052
+ const resumed = await this.tryResumeBlockedFromQueue(channelId, channelState);
1053
+ if (resumed) {
1054
+ continue;
1055
+ }
1056
+ }
1057
+ }
1058
+ }
1059
+ async tryResumeBlockedFromQueue(channelId, channelState) {
1060
+ const blocked = channelState.blocked;
1061
+ if (!blocked || channelState.queue.length === 0) {
1062
+ return false;
1063
+ }
1064
+ const retained = [];
1065
+ for (const [index, queued] of channelState.queue.entries()) {
1066
+ const classified = queued.authorKind === 'human'
1067
+ ? 'human'
1068
+ : await this.classifyQueuedBlockedWakeCandidate(queued, blocked);
1069
+ if (classified === 'agent') {
1070
+ blocked.suppressedAgentMessages += 1;
1071
+ this.recordBlockedSuppressionOutcome(channelId, channelState);
1072
+ continue;
1073
+ }
1074
+ if (classified === 'unknown') {
1075
+ retained.push(queued);
1076
+ continue;
1077
+ }
1078
+ const wakeBlockedState = blocked.awaitingUser;
1079
+ channelState.blocked = undefined;
1080
+ this.awaitingUserByChannel.delete(channelId);
1081
+ channelState.queue = [
1082
+ {
1083
+ ...queued,
1084
+ authorKind: 'human',
1085
+ wakeBlockedState,
1086
+ },
1087
+ ...channelState.queue.slice(index + 1),
1088
+ ];
1089
+ return true;
1090
+ }
1091
+ channelState.queue = retained;
1092
+ return false;
1093
+ }
1094
+ async runCollaborationTurn(channelId, channelState, message) {
1095
+ if (message.kind === 'protocol-turn') {
1096
+ await this.runProtocolManagedTurn(channelId, channelState, message);
1097
+ return;
1098
+ }
1099
+ const taskThreadContext = await this.buildTurnTaskThreadContext(message.raw, channelId);
1100
+ const attentionSnapshot = this.getAttentionSnapshotForTurn(channelId, taskThreadContext);
1101
+ if (!message.wakeBlockedState
1102
+ && this.isHumanWakeFiltered(message.raw, message.authorKind, attentionSnapshot)) {
1103
+ return;
1104
+ }
1105
+ channelState.generation += 1;
1106
+ const activeTurn = {
1107
+ generation: channelState.generation,
1108
+ turnExecutionId: randomUUID(),
1109
+ superseded: false,
1110
+ collaborationMessagesSent: 0,
1111
+ targetCooldowns: new Map(),
1112
+ };
1113
+ this.clearCollaborationDraft(channelId);
1114
+ channelState.recentTurn = undefined;
1115
+ channelState.activeTurn = activeTurn;
1116
+ const executed = await this.executeTurn(message.raw, message.authorId, this.buildIncomingContent(message), {
1117
+ activeTurn,
1118
+ incomingAuthorKind: message.authorKind,
1119
+ attentionSnapshot,
1120
+ taskThreadContext,
1121
+ });
1122
+ channelState.activeTurn = undefined;
1123
+ if (executed.stale) {
1124
+ return;
1125
+ }
1126
+ if (executed.completed) {
1127
+ await this.commitAttentionSnapshot(channelId, executed.appliedAttentionSnapshot ?? attentionSnapshot);
1128
+ this.rememberRecentTurn(channelState, activeTurn);
1129
+ }
1130
+ if (executed.completed && executed.reply?.awaitingUser) {
1131
+ channelState.blocked = {
1132
+ awaitingUser: executed.reply.awaitingUser,
1133
+ suppressedAgentMessages: 0,
1134
+ };
1135
+ this.awaitingUserByChannel.set(channelId, executed.reply.awaitingUser);
1136
+ this.recordCollaborationOutcome(channelId, {
1137
+ responseState: 'blocked',
1138
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1139
+ wakeState: 'waiting',
1140
+ blockedDetails: {
1141
+ question: executed.reply.awaitingUser.question,
1142
+ ...(executed.reply.awaitingUser.reason
1143
+ ? { reason: executed.reply.awaitingUser.reason }
1144
+ : {}),
1145
+ },
1146
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1147
+ });
1148
+ return;
1149
+ }
1150
+ if (!executed.completed && message.wakeBlockedState) {
1151
+ const existing = this.collaborationOutcomeByChannel.get(channelId);
1152
+ channelState.blocked = {
1153
+ awaitingUser: message.wakeBlockedState,
1154
+ suppressedAgentMessages: 0,
1155
+ };
1156
+ this.awaitingUserByChannel.set(channelId, message.wakeBlockedState);
1157
+ this.recordCollaborationOutcome(channelId, {
1158
+ responseState: 'blocked',
1159
+ deliveryState: existing?.responseState === 'blocked' ? existing.deliveryState : 'not-delivered',
1160
+ wakeState: 'waiting',
1161
+ blockedDetails: {
1162
+ question: message.wakeBlockedState.question,
1163
+ ...(message.wakeBlockedState.reason
1164
+ ? { reason: message.wakeBlockedState.reason }
1165
+ : {}),
1166
+ },
1167
+ ...(existing?.turnExecutionId ? { turnExecutionId: existing.turnExecutionId } : {}),
1168
+ });
1169
+ return;
1170
+ }
1171
+ channelState.blocked = undefined;
1172
+ this.awaitingUserByChannel.delete(channelId);
1173
+ this.recordCollaborationOutcome(channelId, {
1174
+ responseState: executed.completed ? 'responded' : 'failed',
1175
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1176
+ ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1177
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1178
+ });
1179
+ }
1180
+ async runProtocolManagedTurn(channelId, channelState, message) {
1181
+ const protocolState = channelState.protocol;
1182
+ const selfAgentId = await this.ensureSelfAgentId();
1183
+ if (!protocolState?.active || protocolState.expectedNextSpeakerId !== selfAgentId) {
1184
+ return;
1185
+ }
1186
+ if (!(await this.canContinueSharedProtocol(channelId, channelState, protocolState))) {
1187
+ return;
1188
+ }
1189
+ channelState.generation += 1;
1190
+ const currentTurnIndex = protocolState.currentTurnIndex + 1;
1191
+ const peerId = this.otherProtocolParticipant(protocolState, selfAgentId);
1192
+ const isFinalTurn = currentTurnIndex >= protocolState.maxTurnCount;
1193
+ const taskThreadContext = message.taskThreadContext
1194
+ ?? await this.buildTurnTaskThreadContext(message.raw, channelId);
1195
+ const attentionSnapshot = message.attentionSnapshot
1196
+ ?? this.getAttentionSnapshotForTurn(channelId, taskThreadContext);
1197
+ const activeTurn = {
1198
+ generation: channelState.generation,
1199
+ turnExecutionId: randomUUID(),
1200
+ superseded: false,
1201
+ collaborationMessagesSent: 0,
1202
+ targetCooldowns: new Map(),
1203
+ delivery: {
1204
+ replyToId: protocolState.lastProtocolMessageId ?? protocolState.anchorMessageId,
1205
+ },
1206
+ protocol: {
1207
+ anchorMessageId: protocolState.anchorMessageId,
1208
+ anchorSummary: summarizeAnchorContent(protocolState.anchorContent),
1209
+ issuerId: protocolState.issuerId,
1210
+ role: protocolState.currentTurnIndex === 0 ? 'issuer' : isFinalTurn ? 'final' : 'intermediate',
1211
+ targetPeerId: peerId,
1212
+ turnIndex: currentTurnIndex,
1213
+ maxTurnCount: protocolState.maxTurnCount,
1214
+ },
1215
+ };
1216
+ this.clearCollaborationDraft(channelId);
1217
+ channelState.recentTurn = undefined;
1218
+ channelState.activeTurn = activeTurn;
1219
+ const executed = await this.executeTurn(message.raw, message.authorId, this.buildIncomingContent(message), {
1220
+ activeTurn,
1221
+ incomingAuthorKind: message.authorKind,
1222
+ attentionSnapshot,
1223
+ taskThreadContext,
1224
+ });
1225
+ channelState.activeTurn = undefined;
1226
+ if (executed.stale || !protocolState.active) {
1227
+ return;
1228
+ }
1229
+ if (executed.completed) {
1230
+ await this.commitAttentionSnapshot(channelId, executed.appliedAttentionSnapshot ?? attentionSnapshot);
1231
+ this.rememberRecentTurn(channelState, activeTurn);
1232
+ }
1233
+ const deliveredMessageId = executed.deliveredMessageId ?? null;
1234
+ const visibleMessageId = deliveredMessageId ?? protocolState.lastProtocolMessageId;
1235
+ if (executed.reply?.controlMalformed) {
1236
+ this.recordCollaborationOutcome(channelId, {
1237
+ responseState: executed.completed ? 'responded' : 'failed',
1238
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1239
+ ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1240
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1241
+ });
1242
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1243
+ return;
1244
+ }
1245
+ if (!executed.reply?.control) {
1246
+ this.recordCollaborationOutcome(channelId, {
1247
+ responseState: executed.completed ? 'responded' : 'failed',
1248
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1249
+ ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1250
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1251
+ });
1252
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1253
+ return;
1254
+ }
1255
+ if (executed.completed && executed.reply.control.kind === 'awaiting-user') {
1256
+ channelState.blocked = {
1257
+ awaitingUser: executed.reply.awaitingUser ?? {
1258
+ question: executed.reply.control.question,
1259
+ ...(executed.reply.control.reason ? { reason: executed.reply.control.reason } : {}),
1260
+ },
1261
+ suppressedAgentMessages: 0,
1262
+ };
1263
+ this.awaitingUserByChannel.set(channelId, channelState.blocked.awaitingUser);
1264
+ this.recordCollaborationOutcome(channelId, {
1265
+ responseState: 'blocked',
1266
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1267
+ wakeState: 'waiting',
1268
+ blockedDetails: {
1269
+ question: channelState.blocked.awaitingUser.question,
1270
+ ...(channelState.blocked.awaitingUser.reason
1271
+ ? { reason: channelState.blocked.awaitingUser.reason }
1272
+ : {}),
1273
+ },
1274
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1275
+ });
1276
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1277
+ return;
1278
+ }
1279
+ if (executed.reply.control.kind === 'start-protocol') {
1280
+ this.recordCollaborationOutcome(channelId, {
1281
+ responseState: executed.completed ? 'responded' : 'failed',
1282
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1283
+ ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1284
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1285
+ });
1286
+ this.updateMissedCollaborationDiagnostic(channelId, undefined);
1287
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1288
+ return;
1289
+ }
1290
+ if (executed.reply.control.kind === 'attention-only') {
1291
+ this.recordCollaborationOutcome(channelId, {
1292
+ responseState: executed.completed ? 'responded' : 'failed',
1293
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1294
+ ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1295
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1296
+ });
1297
+ this.updateMissedCollaborationDiagnostic(channelId, undefined);
1298
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1299
+ return;
1300
+ }
1301
+ if (!deliveredMessageId) {
1302
+ this.finishProtocolState(channelId, channelState, protocolState);
1303
+ this.awaitingUserByChannel.delete(channelId);
1304
+ channelState.blocked = undefined;
1305
+ this.recordCollaborationOutcome(channelId, {
1306
+ responseState: 'failed',
1307
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1308
+ ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1309
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1310
+ });
1311
+ return;
1312
+ }
1313
+ protocolState.currentTurnIndex = currentTurnIndex;
1314
+ if (deliveredMessageId) {
1315
+ protocolState.lastProtocolMessageId = deliveredMessageId;
1316
+ }
1317
+ if (executed.reply.control.kind === 'conclude-locally') {
1318
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1319
+ this.awaitingUserByChannel.delete(channelId);
1320
+ channelState.blocked = undefined;
1321
+ this.recordCollaborationOutcome(channelId, {
1322
+ responseState: 'responded',
1323
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1324
+ ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1325
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1326
+ });
1327
+ return;
1328
+ }
1329
+ if (currentTurnIndex >= protocolState.maxTurnCount) {
1330
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1331
+ this.awaitingUserByChannel.delete(channelId);
1332
+ channelState.blocked = undefined;
1333
+ this.recordCollaborationOutcome(channelId, {
1334
+ responseState: 'responded',
1335
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1336
+ ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1337
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1338
+ });
1339
+ return;
1340
+ }
1341
+ protocolState.expectedNextSpeakerId = peerId;
1342
+ protocolState.fallbackDeadlineAt = nextProtocolFallbackDeadline();
1343
+ await this.persistProtocolState(channelId, protocolState);
1344
+ if (selfAgentId !== protocolState.issuerId) {
1345
+ this.armProtocolFallback(channelId, channelState, protocolState);
1346
+ }
1347
+ channelState.blocked = undefined;
1348
+ this.awaitingUserByChannel.delete(channelId);
1349
+ this.recordCollaborationOutcome(channelId, {
1350
+ responseState: 'responded',
1351
+ deliveryState: executed.deliveryState ?? 'not-delivered',
1352
+ ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1353
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
1354
+ });
1355
+ }
1356
+ buildIncomingContent(message) {
1357
+ if (!message.wakeBlockedState) {
1358
+ return message.content;
1359
+ }
1360
+ const lines = [
1361
+ '[Internal note: a previously blocked human reply has arrived.]',
1362
+ `Pending question: ${message.wakeBlockedState.question}`,
1363
+ ...(message.wakeBlockedState.reason
1364
+ ? [`Blocked reason: ${message.wakeBlockedState.reason}`]
1365
+ : []),
1366
+ `Human reply from ${message.authorId}: ${message.content}`,
1367
+ '',
1368
+ message.content,
1369
+ ];
1370
+ return lines.join('\n');
1371
+ }
1372
+ otherProtocolParticipant(protocolState, currentAgentId) {
1373
+ return protocolState.participantIds[0] === currentAgentId
1374
+ ? protocolState.participantIds[1]
1375
+ : protocolState.participantIds[0];
1376
+ }
1377
+ resolveProtocolStatePath(channelId, ownerAgentId) {
1378
+ return resolve(this.config.stateRootDir, PROTOCOL_STATE_DIRNAME, `${encodeStatePathSegment(channelId)}--${encodeStatePathSegment(ownerAgentId)}.json`);
1379
+ }
1380
+ async ensureProtocolStateLoaded(channelId, channelState) {
1381
+ if (channelState.protocolLoaded) {
1382
+ return;
1383
+ }
1384
+ if (channelState.protocol) {
1385
+ channelState.protocolLoaded = true;
1386
+ return;
1387
+ }
1388
+ channelState.protocolLoaded = true;
1389
+ try {
1390
+ const selfAgentId = await this.ensureSelfAgentId();
1391
+ const content = await readFile(this.resolveProtocolStatePath(channelId, selfAgentId), 'utf8');
1392
+ const parsed = JSON.parse(content);
1393
+ if ((parsed.ownerAgentId && parsed.ownerAgentId !== selfAgentId) ||
1394
+ !parsed.participantIds.includes(selfAgentId)) {
1395
+ return;
1396
+ }
1397
+ const resolvedProtocolState = await this.reconcileOwnProtocolStateFromPeers(channelId, {
1398
+ ...parsed,
1399
+ ownerAgentId: parsed.ownerAgentId ?? selfAgentId,
1400
+ }, selfAgentId);
1401
+ channelState.protocol = {
1402
+ ...resolvedProtocolState,
1403
+ fallbackTimer: null,
1404
+ };
1405
+ if (resolvedProtocolState.currentTurnIndex !== parsed.currentTurnIndex ||
1406
+ resolvedProtocolState.expectedNextSpeakerId !== parsed.expectedNextSpeakerId ||
1407
+ resolvedProtocolState.active !== parsed.active ||
1408
+ resolvedProtocolState.finished !== parsed.finished ||
1409
+ resolvedProtocolState.lastProtocolMessageId !== parsed.lastProtocolMessageId) {
1410
+ await this.persistProtocolState(channelId, channelState.protocol);
1411
+ }
1412
+ if (resolvedProtocolState.active) {
1413
+ this.armProtocolFallback(channelId, channelState, channelState.protocol);
1414
+ }
1415
+ }
1416
+ catch (error) {
1417
+ const code = error.code;
1418
+ if (code !== 'ENOENT') {
1419
+ this.logger.error('failed to load protocol state', {
1420
+ channelId,
1421
+ error: summarizeError(error),
1422
+ });
1423
+ }
1424
+ }
1425
+ }
1426
+ async reconcileOwnProtocolStateFromPeers(channelId, protocolState, selfAgentId) {
1427
+ const directoryPath = join(this.config.stateRootDir, PROTOCOL_STATE_DIRNAME);
1428
+ const channelPrefix = `${encodeStatePathSegment(channelId)}--`;
1429
+ const ownSuffix = `--${encodeStatePathSegment(selfAgentId)}.json`;
1430
+ let entries;
1431
+ try {
1432
+ entries = await readdir(directoryPath);
1433
+ }
1434
+ catch (error) {
1435
+ const code = error.code;
1436
+ if (code === 'ENOENT') {
1437
+ return protocolState;
1438
+ }
1439
+ throw error;
1440
+ }
1441
+ let resolved = protocolState;
1442
+ for (const entry of entries) {
1443
+ if (!entry.startsWith(channelPrefix) || entry.endsWith(ownSuffix)) {
1444
+ continue;
1445
+ }
1446
+ try {
1447
+ const candidate = JSON.parse(await readFile(join(directoryPath, entry), 'utf8'));
1448
+ if (!candidate.participantIds.includes(selfAgentId)) {
1449
+ continue;
1450
+ }
1451
+ if (!shouldAdoptPeerProtocolState(resolved, candidate, selfAgentId)) {
1452
+ continue;
1453
+ }
1454
+ resolved = {
1455
+ ...candidate,
1456
+ ownerAgentId: selfAgentId,
1457
+ };
1458
+ }
1459
+ catch (error) {
1460
+ const code = error.code;
1461
+ if (code === 'ENOENT') {
1462
+ continue;
1463
+ }
1464
+ throw error;
1465
+ }
1466
+ }
1467
+ return resolved;
1468
+ }
1469
+ async loadPersistedProtocolStates() {
1470
+ if (!this.collaborationEnabled) {
1471
+ return;
1472
+ }
1473
+ const selfAgentId = await this.ensureSelfAgentId();
1474
+ const ownSuffix = `--${encodeStatePathSegment(selfAgentId)}.json`;
1475
+ const directoryPath = join(this.config.stateRootDir, PROTOCOL_STATE_DIRNAME);
1476
+ let entries;
1477
+ try {
1478
+ entries = await readdir(directoryPath);
1479
+ }
1480
+ catch (error) {
1481
+ const code = error.code;
1482
+ if (code !== 'ENOENT') {
1483
+ this.logger.error('failed to enumerate protocol state directory', {
1484
+ directoryPath,
1485
+ error: summarizeError(error),
1486
+ });
1487
+ }
1488
+ return;
1489
+ }
1490
+ for (const entry of entries) {
1491
+ if (!entry.endsWith(ownSuffix)) {
1492
+ continue;
1493
+ }
1494
+ const encodedChannelId = entry.slice(0, -ownSuffix.length);
1495
+ const channelId = decodeStatePathSegment(encodedChannelId);
1496
+ if (channelId == null) {
1497
+ continue;
1498
+ }
1499
+ const channelState = this.ensureCollaborationChannelState(channelId);
1500
+ await this.ensureProtocolStateLoaded(channelId, channelState);
1501
+ }
1502
+ }
1503
+ async resumeOwnedProtocolTurns() {
1504
+ if (!this.collaborationEnabled) {
1505
+ return;
1506
+ }
1507
+ const selfAgentId = await this.ensureSelfAgentId();
1508
+ for (const [channelId, channelState] of this.collaborationChannels.entries()) {
1509
+ const protocolState = channelState.protocol;
1510
+ if (!protocolState?.active || protocolState.expectedNextSpeakerId !== selfAgentId) {
1511
+ continue;
1512
+ }
1513
+ if (channelState.activeTurn ||
1514
+ channelState.queue.some((message) => message.kind === 'protocol-turn')) {
1515
+ continue;
1516
+ }
1517
+ const resumed = await this.buildResumedProtocolMessage(channelId, protocolState);
1518
+ if (!resumed) {
1519
+ this.cancelProtocolState(channelId, channelState);
1520
+ continue;
1521
+ }
1522
+ this.enqueueCollaborationMessage(channelState, resumed);
1523
+ await this.ensureCollaborationProcessor(channelId, channelState);
1524
+ }
1525
+ }
1526
+ async buildResumedProtocolMessage(channelId, protocolState) {
1527
+ if (protocolState.currentTurnIndex === 0) {
1528
+ return {
1529
+ kind: 'protocol-turn',
1530
+ raw: {
1531
+ channel_id: channelId,
1532
+ message_id: protocolState.anchorMessageId,
1533
+ user_id: protocolState.anchorAuthorId,
1534
+ content: protocolState.anchorContent,
1535
+ body: protocolState.anchorContent,
1536
+ type: 'message',
1537
+ },
1538
+ authorId: protocolState.anchorAuthorId,
1539
+ authorKind: 'human',
1540
+ content: protocolState.anchorContent,
1541
+ };
1542
+ }
1543
+ if (!protocolState.lastProtocolMessageId) {
1544
+ return null;
1545
+ }
1546
+ let before;
1547
+ for (;;) {
1548
+ const history = await this.borgee.readChannelHistory({
1549
+ channelId,
1550
+ before,
1551
+ limit: 200,
1552
+ });
1553
+ const priorMessage = history.find((entry) => entry.id === protocolState.lastProtocolMessageId);
1554
+ if (priorMessage) {
1555
+ return {
1556
+ kind: 'protocol-turn',
1557
+ raw: {
1558
+ channel_id: channelId,
1559
+ message_id: priorMessage.id,
1560
+ user_id: priorMessage.authorId,
1561
+ content: priorMessage.body,
1562
+ body: priorMessage.body,
1563
+ type: 'message',
1564
+ created_at: priorMessage.createdAt,
1565
+ },
1566
+ authorId: priorMessage.authorId,
1567
+ authorKind: 'agent',
1568
+ content: priorMessage.body,
1569
+ };
1570
+ }
1571
+ if (history.length < 200) {
1572
+ break;
1573
+ }
1574
+ const oldestCreatedAt = history.reduce((oldest, entry) => {
1575
+ return oldest == null || entry.createdAt < oldest ? entry.createdAt : oldest;
1576
+ }, null);
1577
+ if (oldestCreatedAt == null || (before != null && oldestCreatedAt >= before)) {
1578
+ break;
1579
+ }
1580
+ before = oldestCreatedAt;
1581
+ }
1582
+ return null;
1583
+ }
1584
+ async persistProtocolState(channelId, protocolState) {
1585
+ const ownerAgentId = protocolState.ownerAgentId ?? (await this.ensureSelfAgentId());
1586
+ protocolState.ownerAgentId = ownerAgentId;
1587
+ await writePrivateJsonFile(this.resolveProtocolStatePath(channelId, ownerAgentId), {
1588
+ ownerAgentId,
1589
+ anchorAuthorId: protocolState.anchorAuthorId,
1590
+ anchorContent: protocolState.anchorContent,
1591
+ anchorMessageId: protocolState.anchorMessageId,
1592
+ participantIds: protocolState.participantIds,
1593
+ issuerId: protocolState.issuerId,
1594
+ expectedNextSpeakerId: protocolState.expectedNextSpeakerId,
1595
+ currentTurnIndex: protocolState.currentTurnIndex,
1596
+ maxTurnCount: protocolState.maxTurnCount,
1597
+ lastProtocolMessageId: protocolState.lastProtocolMessageId,
1598
+ active: protocolState.active,
1599
+ finished: protocolState.finished,
1600
+ fallbackDeadlineAt: protocolState.fallbackDeadlineAt,
1601
+ });
1602
+ }
1603
+ async persistSharedProtocolStatus(channelId, protocolState) {
1604
+ await writePrivateJsonFile(this.resolveSharedProtocolStatusPath(channelId, protocolState.anchorMessageId), {
1605
+ channelId,
1606
+ anchorMessageId: protocolState.anchorMessageId,
1607
+ active: protocolState.active,
1608
+ finished: protocolState.finished,
1609
+ updatedAt: Date.now(),
1610
+ });
1611
+ }
1612
+ async readSharedProtocolStatus(channelId, anchorMessageId) {
1613
+ try {
1614
+ const content = await readFile(this.resolveSharedProtocolStatusPath(channelId, anchorMessageId), 'utf8');
1615
+ const parsed = JSON.parse(content);
1616
+ if (typeof parsed.active !== 'boolean' || typeof parsed.finished !== 'boolean') {
1617
+ return null;
1618
+ }
1619
+ return { active: parsed.active, finished: parsed.finished };
1620
+ }
1621
+ catch (error) {
1622
+ const code = error.code;
1623
+ if (code === 'ENOENT') {
1624
+ return null;
1625
+ }
1626
+ throw error;
1627
+ }
1628
+ }
1629
+ clearProtocolFallback(channelState) {
1630
+ const timer = channelState?.protocol?.fallbackTimer;
1631
+ if (timer) {
1632
+ clearTimeout(timer);
1633
+ if (channelState?.protocol) {
1634
+ channelState.protocol.fallbackTimer = null;
1635
+ }
1636
+ }
1637
+ }
1638
+ armProtocolFallback(channelId, channelState, protocolState) {
1639
+ this.clearProtocolFallback(channelState);
1640
+ const delay = Math.max(0, protocolState.fallbackDeadlineAt - Date.now());
1641
+ protocolState.fallbackTimer = setTimeout(() => {
1642
+ void this.runProtocolFallback(channelId).catch((error) => {
1643
+ this.logger.error('protocol fallback failed', {
1644
+ channelId,
1645
+ error: summarizeError(error),
1646
+ });
1647
+ });
1648
+ }, delay);
1649
+ }
1650
+ async runProtocolFallback(channelId) {
1651
+ const channelState = this.collaborationChannels.get(channelId);
1652
+ const protocolState = channelState?.protocol;
1653
+ const selfAgentId = await this.ensureSelfAgentId();
1654
+ if (!channelState ||
1655
+ !protocolState?.active ||
1656
+ selfAgentId === protocolState.issuerId ||
1657
+ protocolState.expectedNextSpeakerId !== protocolState.issuerId ||
1658
+ protocolState.fallbackDeadlineAt > Date.now()) {
1659
+ return;
1660
+ }
1661
+ await this.handleProtocolFallbackFailure(channelId, channelState, protocolState, selfAgentId);
1662
+ }
1663
+ async handleProtocolFallbackFailure(channelId, channelState, protocolState, selfAgentId) {
1664
+ if (selfAgentId === protocolState.issuerId) {
1665
+ protocolState.fallbackDeadlineAt = nextProtocolFallbackDeadline();
1666
+ await this.persistProtocolState(channelId, protocolState);
1667
+ return;
1668
+ }
1669
+ const fallbackMessage = {
1670
+ channel_id: channelId,
1671
+ message_id: protocolState.anchorMessageId,
1672
+ user_id: protocolState.anchorAuthorId,
1673
+ content: protocolState.anchorContent,
1674
+ body: protocolState.anchorContent,
1675
+ type: 'message',
1676
+ };
1677
+ this.cancelProtocolState(channelId, channelState);
1678
+ const taskThreadContext = await this.buildTurnTaskThreadContext(fallbackMessage, channelId);
1679
+ const attentionSnapshot = this.getAttentionSnapshotForTurn(channelId, taskThreadContext);
1680
+ await this.runUngatedTurn(fallbackMessage, protocolState.anchorAuthorId, protocolState.anchorContent, taskThreadContext, attentionSnapshot);
1681
+ }
1682
+ finishProtocolState(channelId, channelState, protocolState, lastMessageId) {
1683
+ protocolState.active = false;
1684
+ protocolState.finished = true;
1685
+ if (lastMessageId) {
1686
+ protocolState.lastProtocolMessageId = lastMessageId;
1687
+ }
1688
+ this.clearProtocolFallback(channelState);
1689
+ void this.persistProtocolState(channelId, protocolState);
1690
+ void this.persistSharedProtocolStatus(channelId, protocolState);
1691
+ }
1692
+ cancelProtocolState(channelId, channelState) {
1693
+ if (channelState.activeTurn) {
1694
+ this.supersedeActiveTurn(channelId, channelState.activeTurn);
1695
+ }
1696
+ if (!channelState.protocol) {
1697
+ return;
1698
+ }
1699
+ channelState.protocol.active = false;
1700
+ channelState.protocol.finished = false;
1701
+ this.clearProtocolFallback(channelState);
1702
+ void this.persistProtocolState(channelId, channelState.protocol);
1703
+ void this.persistSharedProtocolStatus(channelId, channelState.protocol);
1704
+ }
1705
+ async confirmProtocolMessage(channelId, messageId, authorId, expectedReplyToId, createdAt) {
1706
+ const history = await this.borgee.readChannelHistory({
1707
+ channelId,
1708
+ before: createdAt != null ? createdAt + 1 : undefined,
1709
+ limit: 20,
1710
+ });
1711
+ const matched = history.find((entry) => entry.id === messageId);
1712
+ if (!matched) {
1713
+ return null;
1714
+ }
1715
+ if (matched.authorId !== authorId || matched.replyToId !== expectedReplyToId) {
1716
+ return null;
1717
+ }
1718
+ return matched;
1719
+ }
1720
+ resolveAttentionStatePath(channelId) {
1721
+ return resolveAttentionStatePath(this.config.stateRootDir, channelId);
1722
+ }
1723
+ buildDefaultAttentionSnapshot(options) {
1724
+ return buildNormalizedAttentionSnapshot({
1725
+ observedAt: options?.observedAt,
1726
+ turnExecutionId: options?.turnExecutionId,
1727
+ });
1728
+ }
1729
+ async buildTurnTaskThreadContext(msg, channelId) {
1730
+ if (msg.message_type === 'task_assignment') {
1731
+ const currentTaskId = extractTaskIdFromTaskAssignmentContent(String(msg.content ?? msg.body ?? ''));
1732
+ return currentTaskId ? { state: 'active', currentTaskId } : { state: 'active' };
1733
+ }
1734
+ if (!this.contextInjectionEnabled) {
1735
+ return { state: 'inactive' };
1736
+ }
1737
+ try {
1738
+ const content = await readFile(resolveChannelContextPayloadPath(this.config.stateRootDir, channelId), 'utf8');
1739
+ const payload = JSON.parse(content);
1740
+ if (payload.taskAssignmentContext?.active !== true) {
1741
+ return { state: 'inactive' };
1742
+ }
1743
+ const currentTaskId = typeof payload.taskAssignmentContext.currentTaskId === 'string'
1744
+ ? payload.taskAssignmentContext.currentTaskId.trim()
1745
+ : '';
1746
+ return currentTaskId ? { state: 'active', currentTaskId } : { state: 'active' };
1747
+ }
1748
+ catch {
1749
+ return { state: 'unavailable' };
1750
+ }
1751
+ }
1752
+ async ensureAttentionSnapshotLoaded(channelId, channelState) {
1753
+ if (!this.attentionFollowSemanticsEnabled) {
1754
+ return;
1755
+ }
1756
+ if (channelState?.attentionLoaded) {
1757
+ return;
1758
+ }
1759
+ if (channelState) {
1760
+ channelState.attentionLoaded = true;
1761
+ }
1762
+ try {
1763
+ const content = await readFile(this.resolveAttentionStatePath(channelId), 'utf8');
1764
+ this.attentionSnapshotByChannel.set(channelId, parsePersistedAttentionSnapshot(JSON.parse(content)));
1765
+ }
1766
+ catch (error) {
1767
+ const code = error.code;
1768
+ if (code !== 'ENOENT') {
1769
+ this.logger.error('failed to load attention state', {
1770
+ channelId,
1771
+ error: summarizeError(error),
1772
+ });
1773
+ }
1774
+ this.attentionSnapshotByChannel.set(channelId, this.buildDefaultAttentionSnapshot());
1775
+ }
1776
+ }
1777
+ revalidateAttentionSnapshotForTurn(channelId, taskThreadContext, options) {
1778
+ const current = this.attentionSnapshotByChannel.get(channelId)
1779
+ ?? this.buildDefaultAttentionSnapshot();
1780
+ if (current.claimContext?.scope !== 'task-thread') {
1781
+ return current;
1782
+ }
1783
+ if (!this.contextInjectionEnabled) {
1784
+ const next = this.buildDefaultAttentionSnapshot({
1785
+ observedAt: options?.observedAt ?? Date.now(),
1786
+ turnExecutionId: options?.turnExecutionId,
1787
+ });
1788
+ this.attentionSnapshotByChannel.set(channelId, next);
1789
+ return next;
1790
+ }
1791
+ if (taskThreadContext.state === 'active' &&
1792
+ taskThreadContext.currentTaskId &&
1793
+ taskThreadContext.currentTaskId === current.claimContext.taskId) {
1794
+ const next = buildNormalizedAttentionSnapshot({
1795
+ ...current,
1796
+ deliveryMode: 'follow',
1797
+ claimState: 'claimed',
1798
+ claimActivation: 'active',
1799
+ observedAt: options?.observedAt ?? Date.now(),
1800
+ turnExecutionId: options?.turnExecutionId,
1801
+ claimContext: current.claimContext,
1802
+ });
1803
+ this.attentionSnapshotByChannel.set(channelId, next);
1804
+ return next;
1805
+ }
1806
+ if (taskThreadContext.state === 'unavailable') {
1807
+ const next = buildNormalizedAttentionSnapshot({
1808
+ ...current,
1809
+ deliveryMode: 'default',
1810
+ claimState: 'claimed',
1811
+ claimActivation: 'dormant',
1812
+ observedAt: options?.observedAt ?? Date.now(),
1813
+ turnExecutionId: options?.turnExecutionId,
1814
+ claimContext: current.claimContext,
1815
+ });
1816
+ this.attentionSnapshotByChannel.set(channelId, next);
1817
+ return next;
1818
+ }
1819
+ const next = this.buildDefaultAttentionSnapshot({
1820
+ observedAt: options?.observedAt ?? Date.now(),
1821
+ turnExecutionId: options?.turnExecutionId,
1822
+ });
1823
+ this.attentionSnapshotByChannel.set(channelId, next);
1824
+ return next;
1825
+ }
1826
+ getAttentionSnapshotForTurn(channelId, taskThreadContext, options) {
1827
+ if (!this.attentionFollowSemanticsEnabled) {
1828
+ return undefined;
1829
+ }
1830
+ return this.revalidateAttentionSnapshotForTurn(channelId, taskThreadContext, options);
1831
+ }
1832
+ isHumanWakeFiltered(msg, authorKind, attentionSnapshot) {
1833
+ if (!this.attentionFollowSemanticsEnabled || authorKind === 'agent') {
1834
+ return false;
1835
+ }
1836
+ if (msg.message_type === 'task_assignment') {
1837
+ return false;
1838
+ }
1839
+ if (this.awaitingUserByChannel.has(msg.channel_id)) {
1840
+ return false;
1841
+ }
1842
+ if (msg.type?.trim() === 'mention') {
1843
+ return false;
1844
+ }
1845
+ const eventKind = msg.type?.trim() ?? '';
1846
+ if (!ORDINARY_HUMAN_EVENT_KINDS.has(eventKind)) {
1847
+ return false;
1848
+ }
1849
+ if (attentionSnapshot?.wakeMode === 'follow-visible-human') {
1850
+ return false;
1851
+ }
1852
+ this.noteWakeSuppressedAttentionPolicy(msg, authorKind);
1853
+ return true;
1854
+ }
1855
+ applyAttentionUpdate(channelId, baseSnapshot, attentionUpdate, taskThreadContext, options) {
1856
+ if (!this.attentionFollowSemanticsEnabled) {
1857
+ return undefined;
1858
+ }
1859
+ if (!attentionUpdate) {
1860
+ return baseSnapshot ?? this.attentionSnapshotByChannel.get(channelId);
1861
+ }
1862
+ const observedAt = options?.observedAt ?? Date.now();
1863
+ const turnExecutionId = options?.turnExecutionId;
1864
+ const current = baseSnapshot
1865
+ ?? this.attentionSnapshotByChannel.get(channelId)
1866
+ ?? this.buildDefaultAttentionSnapshot();
1867
+ const clearClaim = (deliveryMode) => buildNormalizedAttentionSnapshot({
1868
+ deliveryMode,
1869
+ claimState: 'unclaimed',
1870
+ claimActivation: 'inactive',
1871
+ observedAt,
1872
+ turnExecutionId,
1873
+ });
1874
+ switch (attentionUpdate) {
1875
+ case 'follow-channel':
1876
+ return clearClaim('follow');
1877
+ case 'unfollow-channel':
1878
+ return clearClaim('default');
1879
+ case 'mute-channel':
1880
+ return clearClaim('muted');
1881
+ case 'claim-channel':
1882
+ return buildNormalizedAttentionSnapshot({
1883
+ deliveryMode: 'follow',
1884
+ claimState: 'claimed',
1885
+ claimActivation: 'active',
1886
+ observedAt,
1887
+ turnExecutionId,
1888
+ });
1889
+ case 'claim-task-thread':
1890
+ if (!this.contextInjectionEnabled
1891
+ || taskThreadContext.state !== 'active'
1892
+ || !taskThreadContext.currentTaskId) {
1893
+ return current;
1894
+ }
1895
+ return buildNormalizedAttentionSnapshot({
1896
+ deliveryMode: 'follow',
1897
+ claimState: 'claimed',
1898
+ claimActivation: 'active',
1899
+ observedAt,
1900
+ turnExecutionId,
1901
+ claimContext: {
1902
+ scope: 'task-thread',
1903
+ taskId: taskThreadContext.currentTaskId,
1904
+ },
1905
+ });
1906
+ case 'unclaim-channel':
1907
+ return buildNormalizedAttentionSnapshot({
1908
+ deliveryMode: current.deliveryMode,
1909
+ claimState: 'unclaimed',
1910
+ claimActivation: 'inactive',
1911
+ observedAt,
1912
+ turnExecutionId,
1913
+ });
1914
+ case 'unclaim-task-thread':
1915
+ if (!this.contextInjectionEnabled
1916
+ || current.claimContext?.scope !== 'task-thread'
1917
+ || taskThreadContext.state !== 'active'
1918
+ || !taskThreadContext.currentTaskId
1919
+ || taskThreadContext.currentTaskId !== current.claimContext.taskId) {
1920
+ return current;
1921
+ }
1922
+ return buildNormalizedAttentionSnapshot({
1923
+ deliveryMode: current.deliveryMode,
1924
+ claimState: 'unclaimed',
1925
+ claimActivation: 'inactive',
1926
+ observedAt,
1927
+ turnExecutionId,
1928
+ });
1929
+ default:
1930
+ return current;
1931
+ }
1932
+ }
1933
+ async commitAttentionSnapshot(channelId, snapshot) {
1934
+ if (!this.attentionFollowSemanticsEnabled || !snapshot) {
1935
+ return;
1936
+ }
1937
+ this.attentionSnapshotByChannel.set(channelId, snapshot);
1938
+ await writePrivateJsonFile(this.resolveAttentionStatePath(channelId), snapshot);
1939
+ }
1940
+ resolveTaskThreadCollaborationContractForTurn(msg, taskThreadContext) {
1941
+ if (!this.taskThreadCollaborationContractEnabled) {
1942
+ return undefined;
1943
+ }
1944
+ return projectTaskThreadCollaborationContract({
1945
+ incomingMessageType: msg.message_type,
1946
+ taskThreadContextActive: taskThreadContext.state === 'active',
1947
+ });
1948
+ }
1949
+ resolveCollaborationCapabilityDeclarationForTurn() {
1950
+ if (!this.collaborationCapabilitiesDiagnosticsEnabled) {
1951
+ return undefined;
1952
+ }
1953
+ return {
1954
+ ...(this.collaborationOutcomeModelEnabled ? { collaborationOutcome: true } : {}),
1955
+ ...(this.attentionFollowSemanticsEnabled ? { attentionSnapshot: true } : {}),
1956
+ ...(this.taskThreadCollaborationContractEnabled
1957
+ ? { taskThreadCollaborationContract: true }
1958
+ : {}),
1959
+ missedCollaborationDiagnostic: true,
1960
+ recoveryExplanation: 'runtime-local-only',
1961
+ };
1962
+ }
1963
+ resolveMissedCollaborationDiagnosticForTurn(channelId) {
1964
+ if (!this.collaborationCapabilitiesDiagnosticsEnabled) {
1965
+ return undefined;
1966
+ }
1967
+ return this.missedCollaborationDiagnosticByChannel.get(channelId);
1968
+ }
1969
+ updateMissedCollaborationDiagnostic(channelId, diagnostic) {
1970
+ if (!this.collaborationCapabilitiesDiagnosticsEnabled || !diagnostic) {
1971
+ this.missedCollaborationDiagnosticByChannel.delete(channelId);
1972
+ return;
1973
+ }
1974
+ this.missedCollaborationDiagnosticByChannel.set(channelId, diagnostic);
1975
+ }
1976
+ noteWakeSuppressedAttentionPolicy(msg, authorKind) {
1977
+ if (authorKind === 'agent' || msg.message_type === 'task_assignment') {
1978
+ return;
1979
+ }
1980
+ const eventKind = msg.type?.trim() ?? '';
1981
+ if (msg.type?.trim() === 'mention' || !ORDINARY_HUMAN_EVENT_KINDS.has(eventKind)) {
1982
+ return;
1983
+ }
1984
+ this.updateMissedCollaborationDiagnostic(msg.channel_id, {
1985
+ stage: 'wake',
1986
+ reason: 'wake-suppressed-attention-policy',
1987
+ });
1988
+ }
1989
+ async runUngatedTurn(msg, authorId, content, taskThreadContext, attentionSnapshot) {
1990
+ const executed = await this.executeTurn(msg, authorId, content, {
1991
+ attentionSnapshot,
1992
+ taskThreadContext,
1993
+ });
1994
+ if (executed.completed && executed.reply) {
1995
+ this.updateAwaitingUserState(msg.channel_id, executed.reply.awaitingUser);
1996
+ }
1997
+ if (executed.completed) {
1998
+ await this.commitAttentionSnapshot(msg.channel_id, executed.appliedAttentionSnapshot ?? attentionSnapshot);
1999
+ }
2000
+ if (!executed.completed) {
2001
+ const awaitingUser = this.awaitingUserByChannel.get(msg.channel_id);
2002
+ if (awaitingUser) {
2003
+ const existing = this.collaborationOutcomeByChannel.get(msg.channel_id);
2004
+ this.recordCollaborationOutcome(msg.channel_id, {
2005
+ responseState: 'blocked',
2006
+ deliveryState: existing?.responseState === 'blocked' ? existing.deliveryState : 'not-delivered',
2007
+ wakeState: 'waiting',
2008
+ blockedDetails: {
2009
+ question: awaitingUser.question,
2010
+ ...(awaitingUser.reason ? { reason: awaitingUser.reason } : {}),
2011
+ },
2012
+ ...(existing?.turnExecutionId ? { turnExecutionId: existing.turnExecutionId } : {}),
2013
+ });
2014
+ return;
2015
+ }
2016
+ }
2017
+ this.recordCollaborationOutcome(msg.channel_id, {
2018
+ responseState: executed.completed && executed.reply?.awaitingUser
2019
+ ? 'blocked'
2020
+ : executed.completed
2021
+ ? 'responded'
2022
+ : 'failed',
2023
+ deliveryState: executed.deliveryState ?? 'not-delivered',
2024
+ ...(executed.completed && executed.reply?.awaitingUser
2025
+ ? {
2026
+ wakeState: 'waiting',
2027
+ blockedDetails: {
2028
+ question: executed.reply.awaitingUser.question,
2029
+ ...(executed.reply.awaitingUser.reason
2030
+ ? { reason: executed.reply.awaitingUser.reason }
2031
+ : {}),
2032
+ },
2033
+ }
2034
+ : {}),
2035
+ });
2036
+ }
2037
+ async executeTurn(msg, authorId, content, options) {
2038
+ const channelId = msg.channel_id;
2039
+ const activeTurn = options?.activeTurn;
2040
+ const attentionSnapshot = options?.attentionSnapshot;
2041
+ const taskThreadContext = options?.taskThreadContext ?? { state: 'inactive' };
2042
+ const silentTurn = options?.silent === true;
2043
+ const deferDelivery = options?.deferDelivery === true;
2044
+ const collaborationTurnMode = activeTurn?.protocol
2045
+ ? 'protocol-managed'
2046
+ : options?.kickoff
2047
+ ? 'silent-kickoff'
2048
+ : undefined;
2049
+ const turnExecutionId = activeTurn?.turnExecutionId ?? null;
2050
+ const stopTyping = silentTurn ? () => { } : this.borgee.startTyping(channelId);
2051
+ const collaborationGrounding = await this.buildCollaborationGrounding(authorId, options?.incomingAuthorKind, activeTurn, options?.kickoff);
2052
+ const usePublicDraftProgress = !silentTurn &&
2053
+ !deferDelivery &&
2054
+ !this.collaborationEnabled &&
2055
+ options?.incomingAuthorKind !== 'agent' &&
2056
+ providerUsesDraftProgress(this.config.agent.provider) &&
2057
+ activeTurn?.protocol === undefined;
2058
+ const usePrivateCollaborationDraftProgress = !silentTurn &&
2059
+ !deferDelivery &&
2060
+ this.collaborationEnabled &&
2061
+ turnExecutionId != null &&
2062
+ providerUsesDraftProgress(this.config.agent.provider) &&
2063
+ activeTurn?.protocol === undefined;
2064
+ const turnContext = {
2065
+ eventType: msg.type,
2066
+ channelId,
2067
+ messageId: msg.message_id,
2068
+ authorId,
2069
+ contentLength: content.length,
2070
+ provider: this.config.agent.provider,
2071
+ agentName: this.config.agent.agentName,
2072
+ generation: activeTurn?.generation,
2073
+ };
2074
+ const turnStartedAt = Date.now();
2075
+ this.logger.debug('starting turn', turnContext);
2076
+ let reply;
2077
+ let publicReplyText;
2078
+ let visibleReplyBody;
2079
+ const taskThreadCollaborationContract = this.resolveTaskThreadCollaborationContractForTurn(msg, taskThreadContext);
2080
+ const collaborationOutcome = this.collaborationOutcomeModelEnabled
2081
+ ? this.collaborationOutcomeByChannel.get(channelId)
2082
+ : undefined;
2083
+ const missedCollaborationDiagnostic = this.resolveMissedCollaborationDiagnosticForTurn(channelId);
2084
+ const collaborationCapabilities = this.resolveCollaborationCapabilityDeclarationForTurn();
2085
+ try {
2086
+ reply = await this.provider.generateReply({
2087
+ agentName: this.config.agent.agentName,
2088
+ provider: this.config.agent.provider,
2089
+ channelId,
2090
+ incomingAuthorId: authorId,
2091
+ incomingContent: content,
2092
+ incomingEventKind: msg.type,
2093
+ incomingMessageType: msg.message_type,
2094
+ collaboration: turnExecutionId || options?.kickoff
2095
+ ? {
2096
+ enabled: this.collaborationEnabled,
2097
+ ...(turnExecutionId ? { turnExecutionId } : {}),
2098
+ turnMode: collaborationTurnMode ?? 'ordinary',
2099
+ ...(options?.kickoff ? { kickoff: options.kickoff } : {}),
2100
+ ...(activeTurn?.protocol ? { protocol: activeTurn.protocol } : {}),
2101
+ ...(collaborationGrounding ? { grounding: collaborationGrounding } : {}),
2102
+ }
2103
+ : undefined,
2104
+ collaborationOutcome,
2105
+ attentionSnapshot,
2106
+ ...(taskThreadCollaborationContract ? { taskThreadCollaborationContract } : {}),
2107
+ ...(collaborationCapabilities ? { collaborationCapabilities } : {}),
2108
+ ...(missedCollaborationDiagnostic ? { missedCollaborationDiagnostic } : {}),
2109
+ }, usePublicDraftProgress || usePrivateCollaborationDraftProgress
2110
+ ? {
2111
+ onProgress: (update) => {
2112
+ if (!this.canAcceptTurnOutput(channelId, activeTurn?.generation)) {
2113
+ return;
2114
+ }
2115
+ if (usePublicDraftProgress) {
2116
+ this.ensureProgressDraft(channelId, activeTurn?.generation, activeTurn?.delivery).update(update.text);
2117
+ return;
2118
+ }
2119
+ this.publishCollaborationDraft(channelId, activeTurn, update.text);
2120
+ },
2121
+ }
2122
+ : undefined);
2123
+ publicReplyText = resolvePublicReplyText(reply);
2124
+ visibleReplyBody = this.resolveVisibleTurnBody(activeTurn, reply, publicReplyText);
2125
+ const appliedAttentionSnapshot = this.applyAttentionUpdate(channelId, attentionSnapshot, reply.controlMalformed
2126
+ ? undefined
2127
+ : (reply.control && 'attentionUpdate' in reply.control
2128
+ ? reply.control.attentionUpdate
2129
+ : undefined), taskThreadContext, {
2130
+ observedAt: Date.now(),
2131
+ turnExecutionId: activeTurn?.turnExecutionId ?? undefined,
2132
+ });
2133
+ if (!(await this.canAcceptCompletedTurnOutput(channelId, activeTurn))) {
2134
+ await this.discardDraft(channelId);
2135
+ if (activeTurn?.turnExecutionId) {
2136
+ this.clearCollaborationDraft(channelId, {
2137
+ generation: activeTurn.generation,
2138
+ turnExecutionId: activeTurn.turnExecutionId,
2139
+ });
2140
+ }
2141
+ return { reply, stale: true, completed: false };
2142
+ }
2143
+ if (silentTurn) {
2144
+ this.logger.debug('completed turn', {
2145
+ ...turnContext,
2146
+ durationMs: Date.now() - turnStartedAt,
2147
+ replyLength: publicReplyText.length,
2148
+ controlKind: reply?.control?.kind ?? null,
2149
+ controlMalformed: reply?.controlMalformed ?? false,
2150
+ delivery: 'silent',
2151
+ });
2152
+ return {
2153
+ reply,
2154
+ deliveredMessageId: null,
2155
+ publicReplyText,
2156
+ visibleReplyBody,
2157
+ deliveryState: 'not-delivered',
2158
+ appliedAttentionSnapshot,
2159
+ stale: false,
2160
+ completed: true,
2161
+ };
2162
+ }
2163
+ if (deferDelivery) {
2164
+ this.logger.debug('completed turn', {
2165
+ ...turnContext,
2166
+ durationMs: Date.now() - turnStartedAt,
2167
+ replyLength: publicReplyText.length,
2168
+ controlKind: reply?.control?.kind ?? null,
2169
+ controlMalformed: reply?.controlMalformed ?? false,
2170
+ delivery: 'deferred',
2171
+ });
2172
+ return {
2173
+ reply,
2174
+ deliveredMessageId: null,
2175
+ publicReplyText,
2176
+ visibleReplyBody,
2177
+ deliveryState: 'not-delivered',
2178
+ appliedAttentionSnapshot,
2179
+ stale: false,
2180
+ completed: true,
2181
+ };
2182
+ }
2183
+ if (usePublicDraftProgress) {
2184
+ const draft = this.progressDrafts.get(channelId);
2185
+ if (!this.started) {
2186
+ await draft?.discard().catch(() => { });
2187
+ this.progressDrafts.delete(channelId);
2188
+ return { reply, stale: true, completed: false };
2189
+ }
2190
+ const deliveredMessageId = await (draft ?? this.ensureProgressDraft(channelId, activeTurn?.generation, activeTurn?.delivery)).finalize(publicReplyText);
2191
+ this.progressDrafts.delete(channelId);
2192
+ this.logger.debug('completed turn', {
2193
+ ...turnContext,
2194
+ durationMs: Date.now() - turnStartedAt,
2195
+ replyLength: publicReplyText.length,
2196
+ controlKind: reply?.control?.kind ?? null,
2197
+ controlMalformed: reply?.controlMalformed ?? false,
2198
+ delivery: 'draft',
2199
+ });
2200
+ return {
2201
+ reply,
2202
+ deliveredMessageId,
2203
+ publicReplyText,
2204
+ visibleReplyBody,
2205
+ deliveryState: deliveredMessageId ? 'draft-finalized' : 'not-delivered',
2206
+ appliedAttentionSnapshot,
2207
+ stale: false,
2208
+ completed: true,
2209
+ };
2210
+ }
2211
+ else if (this.started && !this.controlPlaneClosed && hasVisibleText(publicReplyText)) {
2212
+ const posted = await this.borgee.postMessage({
2213
+ channelId,
2214
+ body: visibleReplyBody,
2215
+ replyToId: activeTurn?.delivery?.replyToId,
2216
+ });
2217
+ this.logger.debug('completed turn', {
2218
+ ...turnContext,
2219
+ durationMs: Date.now() - turnStartedAt,
2220
+ replyLength: publicReplyText.length,
2221
+ controlKind: reply?.control?.kind ?? null,
2222
+ controlMalformed: reply?.controlMalformed ?? false,
2223
+ delivery: 'post',
2224
+ });
2225
+ if (usePrivateCollaborationDraftProgress) {
2226
+ this.finalizeCollaborationDraft(channelId, activeTurn, posted.messageId, visibleReplyBody);
2227
+ }
2228
+ return {
2229
+ reply,
2230
+ deliveredMessageId: posted.messageId,
2231
+ publicReplyText,
2232
+ visibleReplyBody,
2233
+ deliveryState: 'posted',
2234
+ appliedAttentionSnapshot,
2235
+ stale: false,
2236
+ completed: true,
2237
+ };
2238
+ }
2239
+ this.logger.debug('completed turn', {
2240
+ ...turnContext,
2241
+ durationMs: Date.now() - turnStartedAt,
2242
+ replyLength: publicReplyText.length,
2243
+ delivery: usePublicDraftProgress ? 'draft' : 'post',
2244
+ });
2245
+ if (usePrivateCollaborationDraftProgress && activeTurn?.turnExecutionId) {
2246
+ this.clearCollaborationDraft(channelId, {
2247
+ generation: activeTurn.generation,
2248
+ turnExecutionId: activeTurn.turnExecutionId,
2249
+ });
2250
+ }
2251
+ return {
2252
+ reply,
2253
+ deliveredMessageId: null,
2254
+ publicReplyText,
2255
+ visibleReplyBody,
2256
+ deliveryState: 'not-delivered',
2257
+ appliedAttentionSnapshot,
2258
+ stale: false,
2259
+ completed: true,
2260
+ };
2261
+ }
2262
+ catch (error) {
2263
+ await this.discardDraft(channelId);
2264
+ if (activeTurn?.turnExecutionId) {
2265
+ this.clearCollaborationDraft(channelId, {
2266
+ generation: activeTurn.generation,
2267
+ turnExecutionId: activeTurn.turnExecutionId,
2268
+ });
2269
+ }
2270
+ this.logger.debugError('failed to generate or send reply', {
2271
+ ...turnContext,
2272
+ error,
2273
+ });
2274
+ this.logger.error('failed to generate or send reply', {
2275
+ ...turnContext,
2276
+ error: summarizeError(error),
2277
+ });
2278
+ return {
2279
+ reply,
2280
+ publicReplyText,
2281
+ visibleReplyBody,
2282
+ deliveryState: reply ? 'delivery-failed' : 'not-delivered',
2283
+ stale: activeTurn?.superseded === true,
2284
+ completed: false,
2285
+ };
2286
+ }
2287
+ finally {
2288
+ stopTyping();
2289
+ }
2290
+ }
2291
+ canAcceptTurnOutput(channelId, generation) {
2292
+ if (!this.started || this.controlPlaneClosed) {
2293
+ return false;
2294
+ }
2295
+ if (generation == null) {
2296
+ return true;
2297
+ }
2298
+ const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
2299
+ return (activeTurn?.generation === generation &&
2300
+ !activeTurn.superseded &&
2301
+ activeTurn.turnExecutionId != null);
2302
+ }
2303
+ async canAcceptCompletedTurnOutput(channelId, activeTurn) {
2304
+ if (!this.canAcceptTurnOutput(channelId, activeTurn?.generation)) {
2305
+ return false;
2306
+ }
2307
+ if (!activeTurn?.protocol) {
2308
+ return true;
2309
+ }
2310
+ const channelState = this.collaborationChannels.get(channelId);
2311
+ if (!channelState?.protocol) {
2312
+ return false;
2313
+ }
2314
+ if ((await this.canContinueSharedProtocol(channelId, channelState, channelState.protocol)) &&
2315
+ channelState.protocol.anchorMessageId === activeTurn.protocol.anchorMessageId) {
2316
+ return true;
2317
+ }
2318
+ this.supersedeActiveTurn(channelId, activeTurn);
2319
+ return false;
2320
+ }
2321
+ async canContinueSharedProtocol(channelId, channelState, protocolState) {
2322
+ const sharedStatus = await this.readSharedProtocolStatus(channelId, protocolState.anchorMessageId);
2323
+ if (sharedStatus?.active !== false) {
2324
+ return protocolState.active;
2325
+ }
2326
+ protocolState.active = false;
2327
+ protocolState.finished = sharedStatus.finished;
2328
+ channelState.queue = channelState.queue.filter((message) => message.kind !== 'protocol-turn');
2329
+ channelState.recentTurn = undefined;
2330
+ this.clearProtocolFallback(channelState);
2331
+ return false;
2332
+ }
2333
+ enqueueProgressChannelTurn(channelId, task) {
2334
+ const previous = this.progressChannelQueues.get(channelId) ?? Promise.resolve();
2335
+ const next = previous
2336
+ .catch(() => { })
2337
+ .then(task)
2338
+ .finally(() => {
2339
+ if (this.progressChannelQueues.get(channelId) === next) {
2340
+ this.progressChannelQueues.delete(channelId);
2341
+ }
2342
+ });
2343
+ this.progressChannelQueues.set(channelId, next);
2344
+ return next;
2345
+ }
2346
+ ensureProgressDraft(channelId, generation, creationMetadata) {
2347
+ let draft = this.progressDrafts.get(channelId);
2348
+ if (!draft) {
2349
+ draft = new DraftMessageController(this.borgee, channelId, () => !this.controlPlaneClosed && this.canAcceptTurnOutput(channelId, generation), this.logger, creationMetadata);
2350
+ this.progressDrafts.set(channelId, draft);
2351
+ }
2352
+ return draft;
2353
+ }
2354
+ publishCollaborationDraft(channelId, activeTurn, body) {
2355
+ if (!activeTurn?.turnExecutionId || !hasVisibleText(body)) {
2356
+ return;
2357
+ }
2358
+ const generation = activeTurn.generation;
2359
+ const existing = this.collaborationDrafts.get(channelId);
2360
+ if (existing &&
2361
+ (existing.generation !== generation || existing.turnExecutionId !== activeTurn.turnExecutionId)) {
2362
+ this.clearCollaborationDraft(channelId);
2363
+ }
2364
+ const next = this.collaborationDrafts.get(channelId);
2365
+ if (next?.clearTimer) {
2366
+ clearTimeout(next.clearTimer);
2367
+ next.clearTimer = null;
2368
+ }
2369
+ const snapshot = next?.snapshot ?? {
2370
+ draftId: randomUUID(),
2371
+ channelId,
2372
+ turnExecutionId: activeTurn.turnExecutionId,
2373
+ replyToId: activeTurn.delivery?.replyToId,
2374
+ body,
2375
+ status: 'streaming',
2376
+ updatedAt: Date.now(),
2377
+ };
2378
+ snapshot.body = body;
2379
+ snapshot.status = 'streaming';
2380
+ snapshot.updatedAt = Date.now();
2381
+ delete snapshot.finalMessageId;
2382
+ this.collaborationDrafts.set(channelId, {
2383
+ generation,
2384
+ turnExecutionId: activeTurn.turnExecutionId,
2385
+ snapshot,
2386
+ clearTimer: null,
2387
+ });
2388
+ }
2389
+ finalizeCollaborationDraft(channelId, activeTurn, finalMessageId, finalBody) {
2390
+ if (!activeTurn?.turnExecutionId) {
2391
+ this.clearCollaborationDraft(channelId);
2392
+ return;
2393
+ }
2394
+ if (!finalMessageId || !hasVisibleText(finalBody)) {
2395
+ this.clearCollaborationDraft(channelId, {
2396
+ generation: activeTurn.generation,
2397
+ turnExecutionId: activeTurn.turnExecutionId,
2398
+ });
2399
+ return;
2400
+ }
2401
+ const draft = this.collaborationDrafts.get(channelId);
2402
+ if (!draft ||
2403
+ draft.generation !== activeTurn.generation ||
2404
+ draft.turnExecutionId !== activeTurn.turnExecutionId) {
2405
+ return;
2406
+ }
2407
+ if (draft.clearTimer) {
2408
+ clearTimeout(draft.clearTimer);
2409
+ }
2410
+ draft.snapshot.body = finalBody;
2411
+ draft.snapshot.status = 'finalized';
2412
+ draft.snapshot.updatedAt = Date.now();
2413
+ draft.snapshot.finalMessageId = finalMessageId;
2414
+ draft.clearTimer = setTimeout(() => {
2415
+ this.clearCollaborationDraft(channelId, {
2416
+ generation: activeTurn.generation,
2417
+ turnExecutionId: activeTurn.turnExecutionId,
2418
+ });
2419
+ }, COLLABORATION_LATE_SEND_GRACE_MS);
2420
+ }
2421
+ clearCollaborationDraft(channelId, expected) {
2422
+ const draft = this.collaborationDrafts.get(channelId);
2423
+ if (!draft) {
2424
+ return;
2425
+ }
2426
+ if (expected &&
2427
+ (draft.generation !== expected.generation || draft.turnExecutionId !== expected.turnExecutionId)) {
2428
+ return;
2429
+ }
2430
+ if (draft.clearTimer) {
2431
+ clearTimeout(draft.clearTimer);
2432
+ }
2433
+ this.collaborationDrafts.delete(channelId);
2434
+ }
2435
+ readCollaborationDraft(input) {
2436
+ const draft = this.collaborationDrafts.get(input.channelId);
2437
+ if (!draft || draft.turnExecutionId !== input.turnExecutionId) {
2438
+ return null;
2439
+ }
2440
+ return { ...draft.snapshot };
2441
+ }
2442
+ async discardDraft(channelId) {
2443
+ const draft = this.progressDrafts.get(channelId);
2444
+ if (!draft) {
2445
+ return;
2446
+ }
2447
+ this.progressDrafts.delete(channelId);
2448
+ await draft.discard().catch(() => { });
2449
+ }
2450
+ supersedeActiveTurn(channelId, activeTurn) {
2451
+ if (activeTurn.superseded) {
2452
+ return;
2453
+ }
2454
+ this.recordCollaborationOutcome(channelId, {
2455
+ responseState: 'superseded',
2456
+ deliveryState: 'not-delivered',
2457
+ turnExecutionId: activeTurn.turnExecutionId ?? undefined,
2458
+ });
2459
+ if (activeTurn.turnExecutionId) {
2460
+ this.clearCollaborationDraft(channelId, {
2461
+ generation: activeTurn.generation,
2462
+ turnExecutionId: activeTurn.turnExecutionId,
2463
+ });
2464
+ }
2465
+ activeTurn.superseded = true;
2466
+ activeTurn.turnExecutionId = null;
2467
+ void this.discardDraft(channelId);
2468
+ }
2469
+ invalidateChannelTurn(channelId) {
2470
+ const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
2471
+ if (activeTurn) {
2472
+ activeTurn.turnExecutionId = null;
2473
+ }
2474
+ }
2475
+ rememberRecentTurn(channelState, activeTurn) {
2476
+ if (!activeTurn.turnExecutionId) {
2477
+ channelState.recentTurn = undefined;
2478
+ return;
2479
+ }
2480
+ channelState.recentTurn = {
2481
+ turnExecutionId: activeTurn.turnExecutionId,
2482
+ superseded: activeTurn.superseded,
2483
+ collaborationMessagesSent: activeTurn.collaborationMessagesSent,
2484
+ targetCooldowns: new Map(activeTurn.targetCooldowns),
2485
+ ...(activeTurn.protocol ? { protocol: activeTurn.protocol } : {}),
2486
+ expiresAt: Date.now() + COLLABORATION_LATE_SEND_GRACE_MS,
2487
+ };
2488
+ }
2489
+ resolveAuthorizedCollaborationTurn(channelState, turnExecutionId) {
2490
+ const activeTurn = channelState?.activeTurn;
2491
+ if (activeTurn &&
2492
+ !activeTurn.superseded &&
2493
+ activeTurn.turnExecutionId != null &&
2494
+ activeTurn.turnExecutionId === turnExecutionId) {
2495
+ return activeTurn;
2496
+ }
2497
+ const recentTurn = channelState?.recentTurn;
2498
+ if (!recentTurn) {
2499
+ return undefined;
2500
+ }
2501
+ if (recentTurn.expiresAt <= Date.now()) {
2502
+ channelState.recentTurn = undefined;
2503
+ return undefined;
2504
+ }
2505
+ if (!recentTurn.superseded && recentTurn.turnExecutionId === turnExecutionId) {
2506
+ return recentTurn;
2507
+ }
2508
+ return undefined;
2509
+ }
2510
+ updateAwaitingUserState(channelId, awaitingUser) {
2511
+ if (awaitingUser) {
2512
+ this.awaitingUserByChannel.set(channelId, awaitingUser);
2513
+ return;
2514
+ }
2515
+ this.awaitingUserByChannel.delete(channelId);
2516
+ }
2517
+ recordBlockedSuppressionOutcome(channelId, channelState) {
2518
+ const blocked = channelState.blocked;
2519
+ if (!blocked) {
2520
+ return;
2521
+ }
2522
+ const existing = this.collaborationOutcomeByChannel.get(channelId);
2523
+ this.recordCollaborationOutcome(channelId, {
2524
+ responseState: 'blocked',
2525
+ deliveryState: existing?.deliveryState ?? 'not-delivered',
2526
+ wakeState: 'suppressed-agent',
2527
+ blockedDetails: {
2528
+ question: blocked.awaitingUser.question,
2529
+ ...(blocked.awaitingUser.reason ? { reason: blocked.awaitingUser.reason } : {}),
2530
+ },
2531
+ turnExecutionId: channelState.activeTurn?.turnExecutionId
2532
+ ?? channelState.recentTurn?.turnExecutionId
2533
+ ?? existing?.turnExecutionId,
2534
+ });
2535
+ }
2536
+ recordCollaborationOutcome(channelId, outcome) {
2537
+ this.collaborationOutcomeByChannel.set(channelId, {
2538
+ source: 'host-observed',
2539
+ responseState: outcome.responseState,
2540
+ deliveryState: outcome.deliveryState,
2541
+ ...(outcome.wakeState ? { wakeState: outcome.wakeState } : {}),
2542
+ ...(outcome.blockedDetails ? { blockedDetails: outcome.blockedDetails } : {}),
2543
+ observedAt: outcome.observedAt ?? Date.now(),
2544
+ ...(outcome.turnExecutionId ? { turnExecutionId: outcome.turnExecutionId } : {}),
2545
+ });
2546
+ this.syncMissedCollaborationDiagnosticFromOutcome(channelId);
2547
+ }
2548
+ syncMissedCollaborationDiagnosticFromOutcome(channelId) {
2549
+ if (!this.collaborationCapabilitiesDiagnosticsEnabled) {
2550
+ this.missedCollaborationDiagnosticByChannel.delete(channelId);
2551
+ return;
2552
+ }
2553
+ const outcome = this.collaborationOutcomeByChannel.get(channelId);
2554
+ if (!outcome) {
2555
+ this.missedCollaborationDiagnosticByChannel.delete(channelId);
2556
+ return;
2557
+ }
2558
+ if (outcome.responseState === 'blocked'
2559
+ && outcome.wakeState === 'waiting'
2560
+ && outcome.blockedDetails) {
2561
+ this.updateMissedCollaborationDiagnostic(channelId, {
2562
+ stage: 'response',
2563
+ reason: 'blocked-awaiting-user',
2564
+ });
2565
+ return;
2566
+ }
2567
+ if (outcome.responseState === 'superseded') {
2568
+ this.updateMissedCollaborationDiagnostic(channelId, undefined);
2569
+ return;
2570
+ }
2571
+ if (!this.collaborationOutcomeModelEnabled) {
2572
+ this.updateMissedCollaborationDiagnostic(channelId, undefined);
2573
+ return;
2574
+ }
2575
+ if (outcome.deliveryState === 'delivery-failed') {
2576
+ this.updateMissedCollaborationDiagnostic(channelId, {
2577
+ stage: 'delivery',
2578
+ reason: 'delivery-failed',
2579
+ });
2580
+ return;
2581
+ }
2582
+ if (outcome.deliveryState === 'not-delivered' && outcome.responseState !== 'failed') {
2583
+ this.updateMissedCollaborationDiagnostic(channelId, {
2584
+ stage: 'delivery',
2585
+ reason: 'not-delivered',
2586
+ });
2587
+ return;
2588
+ }
2589
+ this.updateMissedCollaborationDiagnostic(channelId, undefined);
2590
+ }
2591
+ resolveVisibleTurnBody(activeTurn, reply, publicReplyText) {
2592
+ if (!activeTurn?.protocol || !this.shouldRelayProtocolReply(activeTurn, reply)) {
2593
+ return publicReplyText;
2594
+ }
2595
+ return appendVisibleBodyMention(publicReplyText, activeTurn.protocol.targetPeerId);
2596
+ }
2597
+ shouldRelayProtocolReply(activeTurn, reply) {
2598
+ const protocol = activeTurn.protocol;
2599
+ return (protocol != null &&
2600
+ reply.control?.kind === 'continue-to-peer' &&
2601
+ protocol.turnIndex < protocol.maxTurnCount);
2602
+ }
2603
+ async normalizeMessage(msg) {
2604
+ this.logger.debug('received channel event', {
2605
+ eventType: msg.type,
2606
+ messageType: msg.message_type,
2607
+ provider: this.config.agent.provider,
2608
+ agentName: this.config.agent.agentName,
2609
+ channelId: msg.channel_id,
2610
+ messageId: msg.message_id,
2611
+ });
2612
+ if (!isReplyTriggeringEventType(msg.type)) {
2613
+ this.logger.debug('ignored non-triggering channel event', {
2614
+ eventType: msg.type,
2615
+ messageType: msg.message_type,
2616
+ channelId: msg.channel_id,
2617
+ messageId: msg.message_id,
2618
+ });
2619
+ return null;
2620
+ }
2621
+ const selfAgentId = await this.ensureSelfAgentId();
2622
+ const authorId = String(msg.user_id ?? msg.sender_id ?? 'unknown');
2623
+ if (authorId === selfAgentId && msg.message_type !== 'task_assignment') {
2624
+ this.logger.debug('ignored self-authored message', {
2625
+ channelId: msg.channel_id,
2626
+ messageId: msg.message_id,
2627
+ });
2628
+ return null;
2629
+ }
2630
+ const content = String(msg.content ?? msg.body ?? '').trim();
2631
+ if (!content) {
2632
+ this.logger.debug('ignored empty message', {
2633
+ channelId: msg.channel_id,
2634
+ messageId: msg.message_id,
2635
+ });
2636
+ return null;
2637
+ }
2638
+ return {
2639
+ authorId,
2640
+ content,
2641
+ };
2642
+ }
2643
+ async markAssignedTaskInProgress(threadId, content) {
2644
+ try {
2645
+ const selfAgentId = await this.ensureSelfAgentId();
2646
+ const task = await waitForTaskForThread(this.borgee, threadId, {
2647
+ preferredTaskId: extractTaskIdFromTaskAssignmentContent(content),
2648
+ attempts: 10,
2649
+ delayMs: 250,
2650
+ });
2651
+ if (!task || task.status !== 'open' || task.assigneeId !== selfAgentId) {
2652
+ return;
2653
+ }
2654
+ await this.borgee.updateTask({
2655
+ taskId: task.id,
2656
+ status: 'in_progress',
2657
+ });
2658
+ }
2659
+ catch (error) {
2660
+ this.logger.debugError('best-effort task auto-start failed', {
2661
+ channelId: threadId,
2662
+ error,
2663
+ });
2664
+ }
2665
+ }
2666
+ async ensureSelfAgentId() {
2667
+ if (this.selfAgentId) {
2668
+ return this.selfAgentId;
2669
+ }
2670
+ if (!this.selfAgentIdPromise) {
2671
+ this.selfAgentIdPromise = this.borgee
2672
+ .getMe()
2673
+ .then((me) => {
2674
+ this.selfAgentId = me.id;
2675
+ this.selfAgentProfile = me;
2676
+ this.participantKindsByUserId.set(me.id, 'agent');
2677
+ this.participantDirectoryByUserId.set(me.id, {
2678
+ id: me.id,
2679
+ display_name: me.display_name,
2680
+ kind: 'agent',
2681
+ });
2682
+ return me.id;
2683
+ })
2684
+ .catch((error) => {
2685
+ this.selfAgentIdPromise = null;
2686
+ throw error;
2687
+ });
2688
+ }
2689
+ return this.selfAgentIdPromise;
2690
+ }
2691
+ async classifyAuthor(authorId, forceRefreshOnMiss) {
2692
+ const cached = this.participantKindsByUserId.get(authorId);
2693
+ if (cached) {
2694
+ return cached === 'agent' ? 'agent' : 'human';
2695
+ }
2696
+ if (!this.participantDirectorySupported) {
2697
+ return this.inferAuthorKind(authorId);
2698
+ }
2699
+ const now = Date.now();
2700
+ const nextRefreshAt = this.participantRefreshBackoffByUserId.get(authorId) ?? 0;
2701
+ if (forceRefreshOnMiss || now >= nextRefreshAt) {
2702
+ await this.refreshParticipantDirectory();
2703
+ const refreshed = this.participantKindsByUserId.get(authorId);
2704
+ if (refreshed) {
2705
+ this.participantRefreshBackoffByUserId.delete(authorId);
2706
+ return refreshed === 'agent' ? 'agent' : 'human';
2707
+ }
2708
+ this.participantRefreshBackoffByUserId.set(authorId, now + BLOCKED_AUTHOR_REFRESH_BACKOFF_MS);
2709
+ }
2710
+ return 'unknown';
2711
+ }
2712
+ async refreshParticipantDirectory() {
2713
+ if (!this.participantDirectorySupported) {
2714
+ return;
2715
+ }
2716
+ if (!this.participantRefreshPromise) {
2717
+ this.participantRefreshPromise = this.borgee
2718
+ .listUsers()
2719
+ .then((users) => {
2720
+ const nextKinds = new Map();
2721
+ const nextDirectory = new Map();
2722
+ for (const user of users) {
2723
+ nextKinds.set(user.id, user.kind);
2724
+ nextDirectory.set(user.id, user);
2725
+ }
2726
+ if (this.selfAgentId) {
2727
+ nextKinds.set(this.selfAgentId, 'agent');
2728
+ nextDirectory.set(this.selfAgentId, {
2729
+ id: this.selfAgentId,
2730
+ display_name: this.selfAgentProfile?.display_name,
2731
+ kind: 'agent',
2732
+ });
2733
+ }
2734
+ this.participantKindsByUserId.clear();
2735
+ this.participantDirectoryByUserId.clear();
2736
+ for (const [userId, kind] of nextKinds) {
2737
+ this.participantKindsByUserId.set(userId, kind);
2738
+ }
2739
+ for (const [userId, user] of nextDirectory) {
2740
+ this.participantDirectoryByUserId.set(userId, user);
2741
+ }
2742
+ })
2743
+ .catch((error) => {
2744
+ if (this.isUnsupportedSemanticOperation(error, 'list_users')) {
2745
+ this.participantDirectorySupported = false;
2746
+ return;
2747
+ }
2748
+ this.logger.error('failed to refresh participant directory', {
2749
+ error: summarizeError(error),
2750
+ });
2751
+ })
2752
+ .finally(() => {
2753
+ this.participantRefreshPromise = null;
2754
+ });
2755
+ }
2756
+ await this.participantRefreshPromise;
2757
+ }
2758
+ async classifyBlockedWakeAuthor(authorId, blockedState) {
2759
+ void blockedState;
2760
+ return this.classifyAuthor(authorId, true);
2761
+ }
2762
+ async classifyQueuedBlockedWakeCandidate(message, blockedState) {
2763
+ void blockedState;
2764
+ return this.classifyAuthor(message.authorId, true);
2765
+ }
2766
+ inferAuthorKind(authorId) {
2767
+ if (this.selfAgentId === authorId) {
2768
+ return 'agent';
2769
+ }
2770
+ if (authorId.includes('@')) {
2771
+ return 'human';
2772
+ }
2773
+ if (authorId.startsWith('user-') || authorId.startsWith('human-')) {
2774
+ return 'human';
2775
+ }
2776
+ if (authorId.startsWith('agent-')) {
2777
+ return 'agent';
2778
+ }
2779
+ return 'unknown';
2780
+ }
2781
+ async buildCollaborationGrounding(authorId, incomingAuthorKind, activeTurn, kickoff) {
2782
+ if (!activeTurn?.protocol && !kickoff) {
2783
+ return undefined;
2784
+ }
2785
+ const selfAgentId = await this.ensureSelfAgentId();
2786
+ const peerId = activeTurn?.protocol?.targetPeerId ??
2787
+ kickoff?.participantIds.find((participantId) => participantId !== selfAgentId);
2788
+ const participantIds = kickoff?.participantIds ?? (peerId ? [selfAgentId, peerId] : [selfAgentId]);
2789
+ await this.hydratePromptIdentityCache([selfAgentId, authorId, ...participantIds]);
2790
+ return {
2791
+ self: this.resolvePromptIdentity(selfAgentId, 'agent'),
2792
+ incomingAuthor: this.resolvePromptIdentity(authorId, incomingAuthorKind ?? 'unknown'),
2793
+ ...(peerId ? { peer: this.resolvePromptIdentity(peerId, 'agent') } : {}),
2794
+ participants: participantIds.map((participantId) => this.resolvePromptIdentity(participantId, 'agent')),
2795
+ ...(kickoff ? { hostRecognizedKickoffCandidate: true } : {}),
2796
+ };
2797
+ }
2798
+ async hydratePromptIdentityCache(userIds) {
2799
+ if (!this.participantDirectorySupported) {
2800
+ return;
2801
+ }
2802
+ const unresolved = [...new Set(userIds)].filter((userId) => !this.participantDirectoryByUserId.has(userId));
2803
+ if (unresolved.length === 0) {
2804
+ return;
2805
+ }
2806
+ await this.refreshParticipantDirectory();
2807
+ }
2808
+ resolvePromptIdentity(userId, fallbackKind) {
2809
+ const directoryUser = this.participantDirectoryByUserId.get(userId);
2810
+ if (this.selfAgentId === userId) {
2811
+ return {
2812
+ id: userId,
2813
+ displayName: this.selfAgentProfile?.display_name ??
2814
+ directoryUser?.display_name ??
2815
+ this.config.agent.agentName,
2816
+ kind: 'agent',
2817
+ };
2818
+ }
2819
+ return {
2820
+ id: userId,
2821
+ displayName: directoryUser?.display_name,
2822
+ kind: (directoryUser?.kind === 'user' ? 'human' : directoryUser?.kind) ?? fallbackKind,
2823
+ };
2824
+ }
2825
+ isUnsupportedSemanticOperation(error, op) {
2826
+ if (!error || typeof error !== 'object') {
2827
+ return false;
2828
+ }
2829
+ const code = 'code' in error ? error.code : undefined;
2830
+ const message = 'message' in error ? error.message : undefined;
2831
+ return (code === 'bpp.semantic_op_unknown' && typeof message === 'string' && message.includes(op));
2832
+ }
2833
+ authorizeCollaborationSend(input) {
2834
+ if (!this.collaborationEnabled) {
2835
+ return { ok: false, statusCode: 403, error: 'collaboration_not_enabled' };
2836
+ }
2837
+ const channelState = this.collaborationChannels.get(input.channelId);
2838
+ const authorizedTurn = this.resolveAuthorizedCollaborationTurn(channelState, input.turnExecutionId);
2839
+ if (!authorizedTurn) {
2840
+ return { ok: false, statusCode: 409, error: 'stale_turn_execution_id' };
2841
+ }
2842
+ if (authorizedTurn.protocol) {
2843
+ return { ok: false, statusCode: 403, error: 'protocol_managed_turn' };
2844
+ }
2845
+ if (authorizedTurn.collaborationMessagesSent >= COLLABORATION_SENDS_PER_TURN) {
2846
+ return { ok: false, statusCode: 429, error: 'collaboration_quota_exceeded' };
2847
+ }
2848
+ const targetKey = [
2849
+ input.replyToId ? `reply:${input.replyToId}` : '',
2850
+ input.mentions.length > 0 ? `mentions:${[...new Set(input.mentions)].sort().join(',')}` : '',
2851
+ ]
2852
+ .filter((value) => value.length > 0)
2853
+ .join('|');
2854
+ const existingCooldownUntil = authorizedTurn.targetCooldowns.get(targetKey) ?? 0;
2855
+ if (existingCooldownUntil > Date.now()) {
2856
+ return { ok: false, statusCode: 429, error: 'collaboration_target_cooldown' };
2857
+ }
2858
+ const cooldownUntil = Date.now() + LOCALHOST_GATEWAY_COLLABORATION_TARGET_COOLDOWN_MS;
2859
+ authorizedTurn.collaborationMessagesSent += 1;
2860
+ authorizedTurn.targetCooldowns.set(targetKey, cooldownUntil);
2861
+ let settled = false;
2862
+ return {
2863
+ ok: true,
2864
+ statusCode: 200,
2865
+ commit: () => {
2866
+ settled = true;
2867
+ },
2868
+ rollback: () => {
2869
+ if (settled) {
2870
+ return;
2871
+ }
2872
+ settled = true;
2873
+ authorizedTurn.collaborationMessagesSent = Math.max(0, authorizedTurn.collaborationMessagesSent - 1);
2874
+ if (authorizedTurn.targetCooldowns.get(targetKey) === cooldownUntil) {
2875
+ authorizedTurn.targetCooldowns.delete(targetKey);
2876
+ }
2877
+ },
2878
+ };
308
2879
  }
309
2880
  }