fuli-context 0.12.0 → 0.12.2

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 (160) hide show
  1. package/README.md +14 -6
  2. package/README.zh-CN.md +9 -4
  3. package/dist/web/assets/{AboutPage-BAD0iPVX.js → AboutPage-Dized8Uz.js} +1 -1
  4. package/dist/web/assets/{AgentConversations-1iGTd4sz.js → AgentConversations-Ccy41mEA.js} +1 -1
  5. package/dist/web/assets/{AgentHand-D_RafKOM.js → AgentHand-UzVhbKUC.js} +1 -1
  6. package/dist/web/assets/{AgentName-B5LKQ09q.js → AgentName-DJpvkub2.js} +1 -1
  7. package/dist/web/assets/AgentProfilePage-CFViQ8kj.js +1 -0
  8. package/dist/web/assets/AgentsDirectoryPage-CBFTg_IE.js +1 -0
  9. package/dist/web/assets/AgentsDirectoryPage-Cv-vxw2C.css +1 -0
  10. package/dist/web/assets/{ConnectionsPage-Tr2rRVOZ.js → ConnectionsPage-uXcabab_.js} +1 -1
  11. package/dist/web/assets/EmployeeRecruitDialog-D8Hvhc-M.js +1 -0
  12. package/dist/web/assets/EmployeeWorkbenchPage-TBl_HNj6.js +3 -0
  13. package/dist/web/assets/KnowledgeEditDialog-HmiqLC-Y.js +1 -0
  14. package/dist/web/assets/KnowledgeInspector-Cj2yK4Qh.js +1 -0
  15. package/dist/web/assets/KnowledgeOrganizerPage-DT3BROZm.js +1 -0
  16. package/dist/web/assets/KnowledgePage-C4J4VueL.js +1 -0
  17. package/dist/web/assets/KnowledgeWorkspace-DA_dqVRG.js +4 -0
  18. package/dist/web/assets/{NotFoundPage-fetSnp0N.js → NotFoundPage-x_Yd59mm.js} +1 -1
  19. package/dist/web/assets/{OverviewPage-BD4yY2Xz.js → OverviewPage-3eZjQ26v.js} +1 -1
  20. package/dist/web/assets/OverviewPage-8LBN8Shi.css +1 -0
  21. package/dist/web/assets/PersonalProfilePage-CDQk096L.js +1 -0
  22. package/dist/web/assets/PersonalProjectsPage-CJxp4Wyz.js +1 -0
  23. package/dist/web/assets/{ProjectAgentFirstTask-BcxqmKXQ.js → ProjectAgentFirstTask-BkRJ0zwO.js} +1 -1
  24. package/dist/web/assets/{ProjectAgentsPage-BWDJBESb.js → ProjectAgentsPage-BuNNqSEy.js} +8 -8
  25. package/dist/web/assets/{ProjectScopePicker-yodM7lF_.js → ProjectScopePicker-CiDxM2Yx.js} +1 -1
  26. package/dist/web/assets/PublicProjectsPage-ByOT559r.js +1 -0
  27. package/dist/web/assets/{ReviewPage-Cy0Rt4CO.js → ReviewPage-C0Vdr9uq.js} +1 -1
  28. package/dist/web/assets/RoundtablesPage-Drou-B81.css +1 -0
  29. package/dist/web/assets/RoundtablesPage-L3ShEWS3.js +1 -0
  30. package/dist/web/assets/SettingsPage-BE8pWbI6.css +1 -0
  31. package/dist/web/assets/SettingsPage-McYuinGz.js +1 -0
  32. package/dist/web/assets/{SharedJefaReportPage-C56kPciB.js → SharedJefaReportPage-Bg6lIk3E.js} +1 -1
  33. package/dist/web/assets/UiButton-x8hiHNRM.js +1 -0
  34. package/dist/web/assets/UiDialog-mjletK0y.js +1 -0
  35. package/dist/web/assets/{UiDisclosure-D0iMTplF.js → UiDisclosure-_IxeiexW.js} +1 -1
  36. package/dist/web/assets/{UiSegmented-DIJW6wv3.js → UiSegmented-WFfz9Evl.js} +1 -1
  37. package/dist/web/assets/{UiSelect-CMR0QqF6.js → UiSelect-K29jnV-1.js} +1 -1
  38. package/dist/web/assets/{WritingTastePage-QgNzssfF.js → WritingTastePage-DCULB-d2.js} +1 -1
  39. package/dist/web/assets/agent-pins-DS4mNsNt.js +1 -0
  40. package/dist/web/assets/agent-profile-Bh3MJGvb.css +1 -0
  41. package/dist/web/assets/{attention-store-B1DI8wmd.js → attention-store-CA5y8j1R.js} +1 -1
  42. package/dist/web/assets/catalog-D0VEfOAV.js +1 -0
  43. package/dist/web/assets/{client-CR-W5Wzp.js → client-DnwnUBQV.js} +1 -1
  44. package/dist/web/assets/{console-D7GHOMEC.js → console-Cw7TzOSC.js} +1 -1
  45. package/dist/web/assets/conversation-launcher-settings-Yc13viwr.js +1 -0
  46. package/dist/web/assets/{i18n-CVFhT_k7.js → i18n-oJFlt8hW.js} +3 -3
  47. package/dist/web/assets/{identity-CM6Dmy00.js → identity-DSGS4CZc.js} +1 -1
  48. package/dist/web/assets/index-BfHCcUL2.css +1 -0
  49. package/dist/web/assets/index-CxNER7Oo.js +5 -0
  50. package/dist/web/assets/{judgment-types-CJBEyJs1.js → judgment-types-DR931cZ7.js} +1 -1
  51. package/dist/web/assets/{model-WFhQZ3v_.js → model-BJ-UF4cs.js} +1 -1
  52. package/dist/web/assets/{personal-profile-graph-BwCh2M9G.js → personal-profile-graph-SOK0BR0w.js} +1 -1
  53. package/dist/web/assets/project-team-api-C_0qJk_B.js +1 -0
  54. package/dist/web/assets/{shared-report-CeIvrL9a.js → shared-report-CiX64ZiD.js} +1 -1
  55. package/dist/web/assets/useAgentRoster-B84mzZIg.js +1 -0
  56. package/dist/web/assets/{useApi-CROJJdhE-BYhbjaZB.js → useApi-CROJJdhE-7gt0QyQ5.js} +1 -1
  57. package/dist/web/assets/{vue-router-DaxrQwX3.js → vue-router-CCvPGaLD.js} +1 -1
  58. package/dist/web/index.html +15 -14
  59. package/dist/web/licenses/ant-design-icons.txt +22 -0
  60. package/docs/agent-conversations-and-collaboration.md +3 -3
  61. package/docs/agent-roundtable.md +33 -0
  62. package/docs/client-connections.md +108 -0
  63. package/docs/employee-agents.md +3 -3
  64. package/docs/project-agent-memory.md +6 -6
  65. package/graph-provider/fuli_graph/default_project_lead.py +1 -1
  66. package/graph-provider/fuli_graph/project_agent_delegations.py +70 -2
  67. package/graph-provider/fuli_graph/project_agent_memory_routes.py +18 -1
  68. package/graph-provider/fuli_graph/project_agent_remote_origins.py +154 -0
  69. package/graph-provider/fuli_graph/project_agent_task_models.py +1 -1
  70. package/graph-provider/fuli_graph/runtime.py +7 -0
  71. package/graph-provider/fuli_graph/store_project_agent_context.py +3 -2
  72. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +1 -1
  73. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +1 -1
  74. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +5 -5
  75. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +2 -2
  76. package/graph-provider/fuli_graph/store_project_agent_tasks.py +2 -2
  77. package/graph-provider/fuli_graph/store_project_agents.py +6 -6
  78. package/graph-provider/fuli_graph/system_agents.py +12 -2
  79. package/graph-provider/fuli_graph/system_hr_identity.py +2 -2
  80. package/graph-provider/pyproject.toml +2 -1
  81. package/npm-shrinkwrap.json +280 -5
  82. package/package.json +6 -2
  83. package/skills/operating-fuli/SKILL.md +3 -3
  84. package/src/agent-peer/coordinator-http.js +117 -0
  85. package/src/agent-peer/coordinator-service.js +258 -0
  86. package/src/agent-peer/coordinator-store.js +95 -0
  87. package/src/agent-peer/device-identity.js +70 -0
  88. package/src/agent-peer/http.js +47 -0
  89. package/src/agent-peer/node-store.js +127 -0
  90. package/src/agent-peer/origin-proof.js +56 -0
  91. package/src/agent-peer/peer-port.js +140 -0
  92. package/src/agent-peer/peer-runtime.js +298 -0
  93. package/src/agent-peer/peer-transport.js +78 -0
  94. package/src/agent-peer/protocol.js +161 -0
  95. package/src/agent-peer/runner.js +251 -0
  96. package/src/agent-roundtable/http.js +17 -3
  97. package/src/agent-roundtable/service.js +272 -43
  98. package/src/agent-roundtable/tools.js +15 -1
  99. package/src/agent-roundtable/wake.js +12 -3
  100. package/src/agent-tools/graph-definitions.js +14 -5
  101. package/src/agent-tools/graph-handlers.js +1 -0
  102. package/src/agent-tools/interface-catalog.js +10 -1
  103. package/src/agent-tools/project-agent-definitions.js +1 -1
  104. package/src/agents/identity-receipt.js +1 -1
  105. package/src/cli/command-registry.js +3 -0
  106. package/src/cli/connect-command.js +31 -0
  107. package/src/cli/mcp-command.js +101 -0
  108. package/src/cli/peer-command.js +100 -0
  109. package/src/cli/setup-agent-selection.js +2 -2
  110. package/src/cli/setup-command.js +12 -5
  111. package/src/cli.js +16 -0
  112. package/src/employees/catalog/bole.json +2 -2
  113. package/src/graphiti/federated-application.js +4 -1
  114. package/src/graphiti/project-agent-control-plane.js +7 -0
  115. package/src/graphiti/project-agent-task-entry.js +4 -4
  116. package/src/graphiti/project-agent-team-context.js +16 -18
  117. package/src/graphiti/project-team-view.js +82 -0
  118. package/src/graphiti/provider-client.js +9 -0
  119. package/src/graphiti/task-entry-preferences.js +1 -1
  120. package/src/http/api-router.js +10 -1
  121. package/src/http/graph-api-router.js +7 -0
  122. package/src/mcp/runtime.js +2 -1
  123. package/src/mcp/tool-annotations.js +4 -3
  124. package/src/mcp/tool-profiles.js +36 -0
  125. package/src/mcp-server.js +2 -100
  126. package/src/server.js +14 -2
  127. package/src/setup/agent-installation-status.js +5 -3
  128. package/src/setup/agents.js +23 -3
  129. package/src/setup/claude-desktop-config.js +60 -0
  130. package/src/setup/client-connection.js +25 -0
  131. package/src/setup/paths.js +3 -1
  132. package/src/setup/setup.js +1 -1
  133. package/src/setup/stable-package-root.js +29 -0
  134. package/src/system/system-service.js +5 -0
  135. package/dist/web/assets/AgentProfilePage-CioOdkBM.js +0 -1
  136. package/dist/web/assets/AgentsDirectoryPage--1A9nvNA.js +0 -1
  137. package/dist/web/assets/AgentsDirectoryPage-C7OUQH9v.css +0 -1
  138. package/dist/web/assets/EmployeeRecruitDialog-1QevD4Qv.js +0 -1
  139. package/dist/web/assets/EmployeeWorkbenchPage-B0vQIgwA.js +0 -3
  140. package/dist/web/assets/KnowledgeEditDialog-ClnrfPZi.js +0 -1
  141. package/dist/web/assets/KnowledgeInspector-Te1hJZbx.js +0 -1
  142. package/dist/web/assets/KnowledgeOrganizerPage-CCCrWl6Y.js +0 -1
  143. package/dist/web/assets/KnowledgePage-DGOcBwr8.js +0 -1
  144. package/dist/web/assets/KnowledgeWorkspace-cKn1NCRe.js +0 -4
  145. package/dist/web/assets/OverviewPage-By-fUzSG.css +0 -1
  146. package/dist/web/assets/PersonalProfilePage-DGJVP_VB.js +0 -1
  147. package/dist/web/assets/PersonalProjectsPage-D25HYMgD.js +0 -1
  148. package/dist/web/assets/PublicProjectsPage-CyNxVzW3.js +0 -1
  149. package/dist/web/assets/RoundtablesPage-BjVhvwMc.js +0 -1
  150. package/dist/web/assets/RoundtablesPage-Cb0R4GaM.css +0 -1
  151. package/dist/web/assets/SettingsPage-CCZqZtnH.js +0 -1
  152. package/dist/web/assets/SettingsPage-DYz5bTwa.css +0 -1
  153. package/dist/web/assets/UiDialog-CvDxg3B1.js +0 -1
  154. package/dist/web/assets/agent-pins-BcJ_qr8n.js +0 -1
  155. package/dist/web/assets/agent-profile-BZj3ezWe.css +0 -1
  156. package/dist/web/assets/catalog-DbXv0_bR.js +0 -1
  157. package/dist/web/assets/conversation-launcher-settings-Dj2GMzCI.js +0 -1
  158. package/dist/web/assets/index-CJ6Jmok9.css +0 -1
  159. package/dist/web/assets/index-CRa3GnYi.js +0 -5
  160. package/dist/web/assets/useAgentRoster-DCArHlxK.js +0 -1
@@ -0,0 +1,161 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isIP } from 'node:net';
3
+
4
+ export const PEER_PROTOCOL = 'peer/1';
5
+ export const PEER_LIMITS = Object.freeze({
6
+ requestBytes: 64 * 1024,
7
+ responseBytes: 1024 * 1024,
8
+ messageChars: 16_000,
9
+ queuePerNode: 100,
10
+ directoryAgents: 50,
11
+ defaultTtlSeconds: 30 * 60,
12
+ maxTtlSeconds: 30 * 60,
13
+ acceptSeconds: 15,
14
+ leaseSeconds: 60,
15
+ renewSeconds: 15,
16
+ claimWaitMs: 20_000,
17
+ maxDepth: 3,
18
+ maxStartAttempts: 3,
19
+ invitationSeconds: 10 * 60,
20
+ executionSeconds: 900,
21
+ });
22
+ export const MESSAGE_STATUSES = Object.freeze(['queued', 'claimed', 'running', 'answered', 'failed', 'cancelled', 'expired', 'unknown']);
23
+ export const TERMINAL_STATUSES = new Set(['answered', 'failed', 'cancelled', 'expired', 'unknown']);
24
+ export const RESULT_OUTCOMES = new Set(['answered', 'failed', 'cancelled', 'unknown', 'never_started']);
25
+
26
+ const INVITATION_PREFIX = 'fuli-peer-invite:1:';
27
+ const FINGERPRINT = /^[0-9a-f]{64}$/;
28
+ const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
29
+
30
+ export class PeerError extends Error {
31
+ constructor(code, message, status = 400) {
32
+ super(message);
33
+ this.code = code;
34
+ this.status = status;
35
+ }
36
+ }
37
+
38
+ export function isFingerprint(value) {
39
+ return typeof value === 'string' && FINGERPRINT.test(value);
40
+ }
41
+
42
+ export function isOpaqueId(value) {
43
+ return typeof value === 'string' && OPAQUE_ID.test(value);
44
+ }
45
+
46
+ // Persisted and signed times are compared as text, so only the canonical
47
+ // UTC form produced by Date#toISOString is accepted.
48
+ export function canonicalTime(value) {
49
+ if (typeof value !== 'string') return null;
50
+ const time = Date.parse(value);
51
+ return Number.isFinite(time) && new Date(time).toISOString() === value ? time : null;
52
+ }
53
+
54
+ export function shortFingerprint(fingerprint) {
55
+ return fingerprint.slice(0, 24).toUpperCase().match(/.{4}/g).join(' ');
56
+ }
57
+
58
+ // Signatures cover a canonical form, so property order never changes meaning.
59
+ export function canonicalJson(value) {
60
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
61
+ if (value && typeof value === 'object') {
62
+ return `{${Object.keys(value).filter((key) => value[key] !== undefined).sort()
63
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
64
+ }
65
+ return JSON.stringify(value ?? null);
66
+ }
67
+
68
+ export function sha256Hex(value) {
69
+ return createHash('sha256').update(value).digest('hex');
70
+ }
71
+
72
+ export function coordinatorUrl(host, port) {
73
+ if (!isIP(host)) throw new PeerError('invalid_address', 'The coordinator address must be an IP address');
74
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new PeerError('invalid_address', 'Invalid coordinator port');
75
+ return `https://${isIP(host) === 6 ? `[${host}]` : host}:${port}`;
76
+ }
77
+
78
+ export function encodeInvitation({ url, fingerprint, code, expiresAt, name, certificate, authority }) {
79
+ const payload = { a: url, f: fingerprint, c: code, e: expiresAt, n: name, k: certificate, o: authority };
80
+ return INVITATION_PREFIX + Buffer.from(JSON.stringify(payload)).toString('base64url');
81
+ }
82
+
83
+ // An invitation carries the coordinator certificate, so the joining device
84
+ // trusts exactly that certificate from its first connection.
85
+ export function decodeInvitation(text, { now = Date.now() } = {}) {
86
+ const value = String(text ?? '').trim();
87
+ if (!value.startsWith(INVITATION_PREFIX) || value.length > 8192) {
88
+ throw new PeerError('invalid_invitation', 'This is not a FULI device invitation');
89
+ }
90
+ let payload;
91
+ try { payload = JSON.parse(Buffer.from(value.slice(INVITATION_PREFIX.length), 'base64url').toString('utf8')); }
92
+ catch { throw new PeerError('invalid_invitation', 'This is not a FULI device invitation'); }
93
+ const url = parseCoordinatorUrl(payload?.a);
94
+ if (!isFingerprint(payload?.f) || typeof payload?.c !== 'string' || payload.c.length < 32 || payload.c.length > 128
95
+ || typeof payload?.k !== 'string' || typeof payload?.e !== 'string'
96
+ || typeof payload?.o?.keyId !== 'string' || typeof payload?.o?.publicKey !== 'string') {
97
+ throw new PeerError('invalid_invitation', 'This is not a FULI device invitation');
98
+ }
99
+ const certificate = Buffer.from(payload.k, 'base64');
100
+ if (sha256Hex(certificate) !== payload.f) throw new PeerError('invalid_invitation', 'The invitation fingerprint does not match its certificate');
101
+ if (!(canonicalTime(payload.e) > now)) throw new PeerError('invitation_expired', 'This invitation has expired', 410);
102
+ return { url, fingerprint: payload.f, code: payload.c, expiresAt: payload.e,
103
+ name: typeof payload.n === 'string' ? payload.n.slice(0, 80) : '', certificatePem: derToPem(certificate),
104
+ authority: { keyId: payload.o.keyId, publicKey: payload.o.publicKey } };
105
+ }
106
+
107
+ export function parseCoordinatorUrl(value) {
108
+ let url;
109
+ try { url = new URL(String(value ?? '')); } catch { throw new PeerError('invalid_address', 'Invalid coordinator address'); }
110
+ const host = url.hostname.replace(/^\[|\]$/g, '');
111
+ if (url.protocol !== 'https:' || !isIP(host) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
112
+ throw new PeerError('invalid_address', 'The coordinator address must be https://IP:port');
113
+ }
114
+ return url.origin;
115
+ }
116
+
117
+ export function derToPem(der) {
118
+ return `-----BEGIN CERTIFICATE-----\n${Buffer.from(der).toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n`;
119
+ }
120
+
121
+ export function pemToDer(pem) {
122
+ const body = String(pem ?? '').match(/-----BEGIN CERTIFICATE-----([\s\S]+?)-----END CERTIFICATE-----/)?.[1];
123
+ if (!body) throw new PeerError('invalid_certificate', 'Invalid device certificate');
124
+ return Buffer.from(body.replace(/\s+/g, ''), 'base64');
125
+ }
126
+
127
+ // The signed request a sending device makes on behalf of one verified local task.
128
+ export function validateAssertion(assertion, { now = Date.now() } = {}) {
129
+ const fail = (message) => { throw new PeerError('invalid_message', message); };
130
+ if (!assertion || typeof assertion !== 'object' || assertion.protocol !== PEER_PROTOCOL) fail('Unsupported message protocol');
131
+ if (!isOpaqueId(assertion.messageId) || !isOpaqueId(assertion.threadId)) fail('Invalid message identity');
132
+ const { from, to } = assertion;
133
+ if (!isFingerprint(from?.node) || !isOpaqueId(from?.agentId) || typeof from?.name !== 'string' || from.name.length > 120) fail('Invalid sender');
134
+ if (!isFingerprint(to?.node) || !isOpaqueId(to?.bindingId) || !isOpaqueId(to?.agentId)) fail('Invalid recipient');
135
+ if (from.node === to.node) fail('A device cannot send a remote message to itself');
136
+ if (typeof assertion.body !== 'string' || !assertion.body.trim() || assertion.body.length > PEER_LIMITS.messageChars) fail('Invalid message body');
137
+ if (!['auto', 'new'].includes(assertion.conversation)) fail('Invalid conversation policy');
138
+ if (!Number.isSafeInteger(assertion.depth) || assertion.depth < 1 || assertion.depth > PEER_LIMITS.maxDepth) {
139
+ throw new PeerError('budget_exceeded', 'This conversation reached its cross-device depth limit');
140
+ }
141
+ const origin = assertion.origin;
142
+ if (!origin || typeof origin.payload !== 'string' || typeof origin.signature !== 'string'
143
+ || origin.payload.length > 4096 || origin.signature.length > 128 || Object.keys(origin).length !== 2) {
144
+ fail('A remote message needs its origin proof');
145
+ }
146
+ const issued = canonicalTime(assertion.issuedAt);
147
+ const expires = canonicalTime(assertion.expiresAt);
148
+ if (issued === null || expires === null) fail('Message times must be canonical UTC ISO timestamps');
149
+ if (!(issued <= now + 60_000) || !(expires > issued) || expires - issued > PEER_LIMITS.maxTtlSeconds * 1000) fail('Invalid message lifetime');
150
+ if (expires <= now) throw new PeerError('message_expired', 'This message has expired', 410);
151
+ return assertion;
152
+ }
153
+
154
+ export function publicDirectoryEntry(entry) {
155
+ if (!isOpaqueId(entry?.bindingId) || !isOpaqueId(entry?.agentId)) throw new PeerError('invalid_directory', 'Invalid shared Agent');
156
+ const text = (value, max) => typeof value === 'string' ? value.slice(0, max) : null;
157
+ return { bindingId: entry.bindingId, agentId: entry.agentId, name: text(entry.name, 120) ?? entry.agentId,
158
+ employeeNumber: text(entry.employeeNumber, 32), responsibility: text(entry.responsibility, 500),
159
+ projectName: text(entry.projectName, 120),
160
+ clients: (Array.isArray(entry.clients) ? entry.clients : []).filter((client) => isOpaqueId(client)).slice(0, 4) };
161
+ }
@@ -0,0 +1,251 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+
3
+ import { certificateFingerprint, verifyDeviceSignature } from './device-identity.js';
4
+ import { isAuthorityKey, verifyOriginProof } from './origin-proof.js';
5
+ import { PEER_LIMITS, PeerError, validateAssertion } from './protocol.js';
6
+
7
+ const RETRY_MS = 5_000;
8
+ const STARTED_STATES = new Set(['starting', 'running', 'finished']);
9
+
10
+ // Receives asks for this device's shared project leads. Every claim is written
11
+ // down before it is accepted, nothing runs before the coordinator confirmed the
12
+ // acceptance, and an attempt that may have run is never reported as not started.
13
+ export function createPeerRunner({ store, client, roundtable, provider, nodeId, spaceId,
14
+ clock = Date.now, retryMs = RETRY_MS, renewMs = PEER_LIMITS.renewSeconds * 1000, onError = () => {} }) {
15
+ const stopping = new AbortController();
16
+ const running = new Map();
17
+ const handlers = new Map();
18
+ let loop = null;
19
+
20
+ function start() {
21
+ loop ??= run();
22
+ return loop;
23
+ }
24
+
25
+ async function run() {
26
+ recover();
27
+ while (!stopping.signal.aborted) {
28
+ await flush();
29
+ let claim = null;
30
+ try {
31
+ claim = (await client.post('/peer/1/claim', {}, { signal: stopping.signal,
32
+ timeoutMs: PEER_LIMITS.claimWaitMs + 10_000 })).claim;
33
+ } catch (error) {
34
+ if (stopping.signal.aborted) break;
35
+ onError(error);
36
+ await pause(retryMs);
37
+ continue;
38
+ }
39
+ if (claim) handle(claim).catch(onError);
40
+ }
41
+ }
42
+
43
+ // After a restart nothing from a previous run is still being supervised.
44
+ function recover() {
45
+ for (const attempt of store.unfinishedAttempts()) {
46
+ const started = STARTED_STATES.has(attempt.state);
47
+ queue(attempt.message_id, attempt.attempt_id, started ? 'unknown' : 'never_started',
48
+ { error: started ? 'runner_restarted' : 'not_started' });
49
+ }
50
+ }
51
+
52
+ // Tracked so stop() can wait for every handler before the store is closed.
53
+ function handle(claim) {
54
+ const work = handleClaim(claim);
55
+ handlers.set(work, claim.messageId);
56
+ return work.finally(() => handlers.delete(work));
57
+ }
58
+
59
+ async function handleClaim(claim) {
60
+ const { messageId, attemptId } = claim;
61
+ // Not accepted: the coordinator hands the claim out again after its short window.
62
+ if (stopping.signal.aborted) return;
63
+ if (!store.recordAttempt({ messageId, attemptId, bindingId: claim.assertion?.to?.bindingId }).inserted) return;
64
+ let acceptance;
65
+ const acceptedAt = clock();
66
+ try {
67
+ acceptance = await client.post('/peer/1/renew', { messageId, attemptId, state: 'accepted' }, { signal: stopping.signal });
68
+ if (stopping.signal.aborted) throw new PeerError('stopped', 'The runner stopped while accepting', 499);
69
+ } catch (error) {
70
+ // The acceptance may or may not have been recorded; the coordinator decides
71
+ // between requeueing and unknown. Nothing was started here either way.
72
+ store.setAttemptState(messageId, attemptId, 'not_started', error.code ?? 'accept_failed');
73
+ queue(messageId, attemptId, 'never_started', { error: error.code ?? 'accept_failed' });
74
+ return flush();
75
+ }
76
+ store.setAttemptState(messageId, attemptId, 'accepted');
77
+ if (acceptance?.cancel) {
78
+ store.setAttemptState(messageId, attemptId, 'not_started', 'cancelled');
79
+ queue(messageId, attemptId, 'cancelled', { error: 'cancelled' });
80
+ return flush();
81
+ }
82
+ let authorized;
83
+ try {
84
+ authorized = authorize(claim);
85
+ } catch (error) {
86
+ store.setAttemptState(messageId, attemptId, 'rejected', error.code ?? 'rejected');
87
+ queue(messageId, attemptId, 'failed', { error: error.code ?? 'rejected' });
88
+ return flush();
89
+ }
90
+ if (stopping.signal.aborted) {
91
+ store.setAttemptState(messageId, attemptId, 'not_started', 'stopped');
92
+ queue(messageId, attemptId, 'never_started', { error: 'stopped' });
93
+ return flush();
94
+ }
95
+ store.setAttemptState(messageId, attemptId, 'starting');
96
+ const outcome = await supervise(claim, authorized, acceptedAt);
97
+ store.setAttemptState(messageId, attemptId, 'finished', outcome.outcome);
98
+ queue(messageId, attemptId, outcome.outcome, outcome);
99
+ return flush();
100
+ }
101
+
102
+ // Every check that decides whether this device may act, before any authority is issued.
103
+ function authorize(claim) {
104
+ const { assertion, signature, sender } = claim;
105
+ validateAssertion(assertion, { now: clock() });
106
+ if (!sender || certificateFingerprint(sender.certPem) !== sender.nodeId || assertion.from.node !== sender.nodeId) {
107
+ throw new PeerError('sender_mismatch', 'The message sender does not match its device', 403);
108
+ }
109
+ if (!verifyDeviceSignature(sender.certPem, assertion, signature)) throw new PeerError('invalid_signature', 'Invalid device signature', 403);
110
+ if (assertion.to.node !== nodeId) throw new PeerError('wrong_device', 'The message is for another device', 403);
111
+ const share = store.share(assertion.to.bindingId);
112
+ if (!share || share.agentId !== assertion.to.agentId || share.spaceId !== spaceId()) {
113
+ throw new PeerError('agent_not_shared', 'That Agent is not shared on this device', 403);
114
+ }
115
+ const authority = pinnedAuthority(sender);
116
+ const origin = verifyOriginProof(assertion, authority, { now: clock() });
117
+ const earlier = store.attemptsFor(claim.messageId)
118
+ .filter((attempt) => attempt.attempt_id !== claim.attemptId && STARTED_STATES.has(attempt.state));
119
+ if (earlier.length) throw new PeerError('already_started', 'This message already started on this device', 409);
120
+ return { share, origin };
121
+ }
122
+
123
+ function pinnedAuthority(sender) {
124
+ const pinned = store.authority(sender.nodeId);
125
+ if (pinned) {
126
+ if (sender.authority && (sender.authority.keyId !== pinned.keyId || sender.authority.publicKey !== pinned.publicKey)) {
127
+ throw new PeerError('authority_changed', 'The sending device presented a different origin key; pair it again', 403);
128
+ }
129
+ return pinned;
130
+ }
131
+ // Devices other than the coordinator are bound on first contact from the
132
+ // coordinator's pairing record, and pinned from then on.
133
+ if (!isAuthorityKey(sender.authority)) throw new PeerError('authority_unknown', 'The sending device has no origin key', 403);
134
+ const { bound, authority } = store.bindAuthority(sender.nodeId, sender.authority, 'coordinator');
135
+ if (!bound) throw new PeerError('authority_changed', 'The sending device presented a different origin key; pair it again', 403);
136
+ return authority;
137
+ }
138
+
139
+ async function supervise(claim, { share }, acceptedAt) {
140
+ const { messageId, attemptId } = claim;
141
+ const controller = new AbortController();
142
+ let reason = null;
143
+ const stop = (why) => { reason ??= why; controller.abort(why); };
144
+ const stopOnShutdown = () => stop('stopped');
145
+ stopping.signal.addEventListener('abort', stopOnShutdown, { once: true });
146
+ if (stopping.signal.aborted) stopOnShutdown();
147
+ let lastRenewal = acceptedAt;
148
+ let leaseTimer;
149
+ const armDeadline = () => {
150
+ clearTimeout(leaseTimer);
151
+ if (controller.signal.aborted) return;
152
+ const remaining = PEER_LIMITS.leaseSeconds * 1000 - (clock() - lastRenewal);
153
+ if (remaining <= 0) stop('lease_lost');
154
+ else leaseTimer = setTimeout(() => stop('lease_lost'), remaining);
155
+ };
156
+ armDeadline();
157
+ async function renew(state) {
158
+ const sentAt = clock();
159
+ try {
160
+ const answer = await client.post('/peer/1/renew', { messageId, attemptId, state }, { signal: controller.signal });
161
+ if (controller.signal.aborted) return;
162
+ lastRenewal = Math.max(lastRenewal, sentAt);
163
+ armDeadline();
164
+ if (answer.cancel) stop('cancelled');
165
+ } catch (error) {
166
+ if (controller.signal.aborted) return;
167
+ if (error.status === 403 || error.status === 409) stop(error.status === 403 ? 'cancelled' : 'lease_lost');
168
+ }
169
+ }
170
+ const renewals = (async () => {
171
+ while (!controller.signal.aborted) {
172
+ try { await delay(renewMs, undefined, { signal: controller.signal }); } catch { return; }
173
+ const state = store.attemptsFor(messageId).find((attempt) => attempt.attempt_id === attemptId)?.state;
174
+ await renew(state === 'running' ? 'running' : 'starting');
175
+ }
176
+ })();
177
+ running.set(messageId, { controller, share, stop });
178
+ try {
179
+ const answer = await roundtable.answerRemote({ claim, share, signal: controller.signal,
180
+ timeoutMs: PEER_LIMITS.executionSeconds * 1000,
181
+ onSpawn: () => {
182
+ try { store.setAttemptState(messageId, attemptId, 'running'); }
183
+ catch { stop('persistence_failed'); return; }
184
+ void renew('running');
185
+ },
186
+ issueGrant: (delivery, lifetime) => provider.issueRemoteAgentDelegation({
187
+ personal_space_id: share.spaceId, personal_project_id: share.projectId, agent_id: share.agentId,
188
+ target_application: delivery.client,
189
+ target_session_id: delivery.client === 'codex' ? delivery.sessionId : null,
190
+ lifetime_seconds: Math.max(30, Math.min(900, Math.round(lifetime))),
191
+ origin_node: claim.sender.nodeId, origin_message_id: messageId, origin_attempt_id: attemptId }) });
192
+ if (controller.signal.aborted) throw new PeerError('cancelled', 'The delivery was stopped', 499);
193
+ return { outcome: 'answered', reply: answer.body, via: answer.via, verified: true };
194
+ } catch (error) {
195
+ if (reason === 'lease_lost') return { outcome: 'unknown', error: 'lease_lost' };
196
+ if (reason === 'cancelled' || reason === 'stopped' || error.code === 'cancelled') return { outcome: 'cancelled', error: reason ?? 'cancelled' };
197
+ return { outcome: 'failed', error: error.code ?? 'wake_failed' };
198
+ } finally {
199
+ controller.abort('finished');
200
+ clearTimeout(leaseTimer);
201
+ stopping.signal.removeEventListener('abort', stopOnShutdown);
202
+ running.delete(messageId);
203
+ await renewals;
204
+ }
205
+ }
206
+
207
+ function queue(messageId, attemptId, outcome, { reply = null, via = null, verified = false, error = null } = {}) {
208
+ store.queueResult(messageId, attemptId, { messageId, attemptId, outcome, reply, via, verified, error });
209
+ }
210
+
211
+ async function flush() {
212
+ for (const pending of store.pendingResults()) {
213
+ try {
214
+ await client.post('/peer/1/result', pending.payload);
215
+ store.markDelivered(pending.messageId, pending.attemptId);
216
+ } catch (error) {
217
+ // The coordinator already moved on from this attempt; keep the local record only.
218
+ if (error.status === 404 || error.status === 409) store.markDelivered(pending.messageId, pending.attemptId);
219
+ else return;
220
+ }
221
+ }
222
+ }
223
+
224
+ async function pause(ms) {
225
+ try { await delay(ms, undefined, { signal: stopping.signal }); } catch { /* stopping */ }
226
+ }
227
+
228
+ async function stop() {
229
+ stopping.abort();
230
+ for (const { stop } of running.values()) stop('stopped');
231
+ await loop?.catch(() => {});
232
+ await Promise.allSettled([...handlers.keys()]);
233
+ await flush();
234
+ }
235
+
236
+ async function reconcileShares(validBindings = null) {
237
+ const cancelled = new Set();
238
+ for (const [messageId, { share, stop }] of running) {
239
+ const current = store.share(share.bindingId);
240
+ if (!current || (validBindings && !validBindings.has(share.bindingId))
241
+ || current.workingDirectory !== share.workingDirectory
242
+ || share.clients.some((client) => !current.clients.includes(client))) {
243
+ cancelled.add(messageId);
244
+ stop('cancelled');
245
+ }
246
+ }
247
+ await Promise.allSettled([...handlers].filter(([, id]) => cancelled.has(id)).map(([work]) => work));
248
+ }
249
+
250
+ return { start, stop, flush, handle, reconcileShares, active: () => running.size };
251
+ }
@@ -1,14 +1,28 @@
1
- import { sendJson } from '../http/response.js';
1
+ import { readJson, sendJson } from '../http/response.js';
2
2
 
3
- // Read-only console view of Agent-to-Agent threads.
3
+ // Console view of Agent-to-Agent threads, plus the owner's status and cancel
4
+ // for one message (the same actions Agents have as MCP tools).
4
5
  export async function handleRoundtableApiRequest({ request, response, url, roundtable }) {
5
- if (!roundtable || request.method !== 'GET') return false;
6
+ if (!roundtable) return false;
7
+ const message = /^\/api\/roundtable\/messages\/([^/]+)(\/cancel)?$/.exec(url.pathname);
8
+ if (message && request.method === 'GET' && !message[2]) {
9
+ sendJson(response, 200, await roundtable.ownerMessageStatus({ messageId: decodeURIComponent(message[1]) }));
10
+ return true;
11
+ }
12
+ if (message && request.method === 'POST' && message[2]) {
13
+ await readJson(request);
14
+ sendJson(response, 200, await roundtable.ownerCancelMessage({ messageId: decodeURIComponent(message[1]) }));
15
+ return true;
16
+ }
17
+ if (request.method !== 'GET') return false;
6
18
  if (url.pathname === '/api/roundtable/threads') {
19
+ await roundtable.refreshRemote?.();
7
20
  sendJson(response, 200, roundtable.threads({ limit: Math.min(Number(url.searchParams.get('limit')) || 50, 200) }));
8
21
  return true;
9
22
  }
10
23
  const match = /^\/api\/roundtable\/threads\/([^/]+)$/.exec(url.pathname);
11
24
  if (!match) return false;
25
+ await roundtable.refreshRemote?.({ threadId: decodeURIComponent(match[1]) });
12
26
  sendJson(response, 200, roundtable.thread({ threadId: decodeURIComponent(match[1]) }));
13
27
  return true;
14
28
  }