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,56 @@
1
+ import { createPublicKey, verify } from 'node:crypto';
2
+
3
+ import { canonicalJson, canonicalTime, PeerError, sha256Hex } from './protocol.js';
4
+
5
+ const PROOF_VERSION = 'fuli-remote-origin/1';
6
+ const CLOCK_SKEW_MS = 60_000;
7
+
8
+ // The digest the sending Provider signs: the whole message except the proof itself.
9
+ export function assertionDigest(assertion) {
10
+ const { origin: _origin, ...signed } = assertion;
11
+ return sha256Hex(canonicalJson(signed));
12
+ }
13
+
14
+ export function isAuthorityKey(value) {
15
+ return Boolean(value) && typeof value.keyId === 'string' && /^[0-9a-f]{32}$/.test(value.keyId)
16
+ && typeof value.publicKey === 'string' && Buffer.from(value.publicKey, 'base64url').length === 32;
17
+ }
18
+
19
+ export function authorityFromProvider(response) {
20
+ const key = { keyId: response?.key_id, publicKey: response?.public_key };
21
+ if (response?.algorithm !== 'Ed25519' || !isAuthorityKey(key)) {
22
+ throw new PeerError('authority_unavailable', 'The local FULI authority returned an invalid origin key', 502);
23
+ }
24
+ return key;
25
+ }
26
+
27
+ // A device signature only proves which installation relayed a message. This
28
+ // proves the sender's own FULI authority let its current project lead send
29
+ // exactly this message to exactly this binding, until a short expiry.
30
+ export function verifyOriginProof(assertion, authority, { now = Date.now() } = {}) {
31
+ const fail = (message) => { throw new PeerError('origin_rejected', message, 403); };
32
+ if (!isAuthorityKey(authority)) fail('No origin authority was bound when this device was paired');
33
+ const proof = assertion?.origin;
34
+ if (typeof proof?.payload !== 'string' || typeof proof?.signature !== 'string'
35
+ || proof.payload.length > 4096 || proof.signature.length > 128) fail('The message has no origin proof');
36
+ const payload = Buffer.from(proof.payload, 'base64url');
37
+ const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: authority.publicKey }, format: 'jwk' });
38
+ let valid = false;
39
+ try { valid = verify(null, payload, key, Buffer.from(proof.signature, 'base64url')); } catch { valid = false; }
40
+ if (!valid) fail('The origin proof signature is invalid');
41
+ let claims;
42
+ try { claims = JSON.parse(payload.toString('utf8')); } catch { fail('The origin proof is malformed'); }
43
+ if (claims?.version !== PROOF_VERSION || claims.key_id !== authority.keyId) fail('The origin proof uses another authority');
44
+ if (claims.digest !== assertionDigest(assertion)) fail('The origin proof covers a different message');
45
+ if (claims.message_id !== assertion.messageId || claims.agent_id !== assertion.from.agentId
46
+ || claims.target_node !== assertion.to.node || claims.binding_id !== assertion.to.bindingId
47
+ || claims.expires_at !== assertion.expiresAt) fail('The origin proof does not match the message');
48
+ if (typeof claims.space_id !== 'string' || typeof claims.project_id !== 'string' || typeof claims.task_hash !== 'string') {
49
+ fail('The origin proof has no source scope');
50
+ }
51
+ const issued = canonicalTime(claims.issued_at);
52
+ const expires = canonicalTime(claims.expires_at);
53
+ if (issued === null || expires === null || issued > now + CLOCK_SKEW_MS) fail('The origin proof has invalid times');
54
+ if (expires <= now) throw new PeerError('origin_expired', 'The origin proof has expired', 410);
55
+ return { spaceId: claims.space_id, projectId: claims.project_id, agentId: claims.agent_id, taskHash: claims.task_hash };
56
+ }
@@ -0,0 +1,140 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+
6
+ import { loadDeviceIdentity } from './device-identity.js';
7
+ import { createNodeStore } from './node-store.js';
8
+ import { assertionDigest, authorityFromProvider } from './origin-proof.js';
9
+ import { createPeerClient } from './peer-transport.js';
10
+ import { PEER_LIMITS, PEER_PROTOCOL, PeerError, TERMINAL_STATUSES } from './protocol.js';
11
+
12
+ const ADDRESS = /^peer:([0-9a-f]{16}):([A-Za-z0-9][A-Za-z0-9._:-]{0,127})$/;
13
+ const STATUS_POLL_MS = 2_000;
14
+ const PROOF_MARGIN_MS = 60_000;
15
+
16
+ export function peerDirectory(dataDir) {
17
+ return join(dataDir, 'peer');
18
+ }
19
+
20
+ // What Agents on this device use to reach shared project leads on other paired
21
+ // devices. It never pairs, shares or listens: those stay with the local owner.
22
+ export function createPeerPort({ dataDir, provider, spaceId, clock = Date.now, idFactory = randomUUID,
23
+ statusPollMs = STATUS_POLL_MS }) {
24
+ const directory = peerDirectory(dataDir);
25
+ let store = null;
26
+ let session = null;
27
+
28
+ const databasePath = join(directory, 'node.sqlite');
29
+ const nodeStore = () => (store ??= createNodeStore(databasePath));
30
+ // Nothing is created on disk until the owner configures the LAN roundtable.
31
+ const configured = () => Boolean(store) || existsSync(databasePath);
32
+ const membership = () => (configured() ? nodeStore().setting('membership') : null);
33
+
34
+ async function connection() {
35
+ const current = membership();
36
+ if (!current?.coordinator) throw new PeerError('peer_not_paired', 'This device is not paired with a coordinator; the owner pairs devices in Settings', 409);
37
+ if (session?.fingerprint === current.coordinator.fingerprint && session.url === current.coordinator.url) return session;
38
+ session?.client.close();
39
+ const identity = await loadDeviceIdentity(directory);
40
+ session = { fingerprint: current.coordinator.fingerprint, url: current.coordinator.url, identity,
41
+ client: createPeerClient({ identity, coordinator: current.coordinator }) };
42
+ return session;
43
+ }
44
+
45
+ async function post(path, body, options) {
46
+ return (await connection()).client.post(path, body, options);
47
+ }
48
+
49
+ function isRemoteAddress(to) {
50
+ return typeof to === 'string' && to.trim().startsWith('peer:');
51
+ }
52
+
53
+ async function remoteDirectory() {
54
+ if (!membership()?.coordinator) return null;
55
+ const { devices } = await post('/peer/1/directory', {});
56
+ const self = (await connection()).identity.fingerprint;
57
+ return devices.filter((device) => device.nodeId !== self);
58
+ }
59
+
60
+ async function remoteAgents({ query = null } = {}) {
61
+ let devices;
62
+ try { devices = await remoteDirectory(); }
63
+ catch (error) { return { agents: [], unavailable: error.code ?? 'coordinator_unreachable' }; }
64
+ if (!devices) return null;
65
+ const needle = query?.trim().toLowerCase();
66
+ const agents = devices.flatMap((device) => device.agents.map((agent) => ({
67
+ address: `peer:${device.nodeId.slice(0, 16)}:${agent.bindingId}`, name: agent.name, role: 'shared_project_lead',
68
+ employeeNumber: agent.employeeNumber, responsibility: agent.responsibility, projectName: agent.projectName,
69
+ device: { name: device.name, online: device.online } })))
70
+ .filter((agent) => !needle || [agent.name, agent.projectName, agent.responsibility, agent.device.name]
71
+ .some((value) => value?.toLowerCase().includes(needle)));
72
+ return { agents: agents.slice(0, 50) };
73
+ }
74
+
75
+ async function resolveRemote(to) {
76
+ const match = ADDRESS.exec(String(to ?? '').trim());
77
+ if (!match) throw new PeerError('invalid_address', 'Remote Agents are addressed as peer:<device>:<binding> from find_agents');
78
+ const device = (await remoteDirectory() ?? []).find((entry) => entry.nodeId.startsWith(match[1]));
79
+ const agent = device?.agents.find((entry) => entry.bindingId === match[2]);
80
+ if (!agent) throw new PeerError('agent_not_shared', 'That remote Agent is not shared any more; call find_agents again', 404);
81
+ return { address: match[0], node: device.nodeId, bindingId: agent.bindingId, agentId: agent.agentId,
82
+ name: agent.name, deviceName: device.name };
83
+ }
84
+
85
+ // The task token is only used with this device's own authority; the remote
86
+ // device receives the authority's signed statement, never the token.
87
+ async function send({ messageId, threadId, target, body, conversation, depth, from, taskContextToken, sourceApplication }) {
88
+ const { identity } = await connection();
89
+ const now = clock();
90
+ const assertion = { protocol: PEER_PROTOCOL, messageId, threadId,
91
+ from: { node: identity.fingerprint, agentId: from.agentId, name: from.name, client: from.client },
92
+ to: { node: target.node, bindingId: target.bindingId, agentId: target.agentId },
93
+ body, conversation, depth,
94
+ issuedAt: new Date(now).toISOString(),
95
+ expiresAt: new Date(now + PEER_LIMITS.defaultTtlSeconds * 1000 - PROOF_MARGIN_MS).toISOString() };
96
+ const issued = await provider.issueRemoteAgentOrigin({ personal_space_id: spaceId(), task_context_token: taskContextToken,
97
+ source_application: sourceApplication, message_id: messageId, digest: assertionDigest(assertion),
98
+ target_node: target.node, binding_id: target.bindingId, expires_at: assertion.expiresAt });
99
+ if (issued.agent_id !== from.agentId) throw new PeerError('origin_mismatch', 'The local authority attributed this task to another Agent', 409);
100
+ assertion.origin = issued.proof;
101
+ nodeStore().recordSent({ messageId, targetNode: target.node, bindingId: target.bindingId });
102
+ return post('/peer/1/messages', { assertion, signature: identity.sign(assertion) });
103
+ }
104
+
105
+ async function status(messageId, options) {
106
+ return post('/peer/1/status', { messageId }, options);
107
+ }
108
+
109
+ async function cancel(messageId) {
110
+ return post('/peer/1/cancel', { messageId });
111
+ }
112
+
113
+ async function waitFor(messageId, timeoutMs) {
114
+ const deadline = clock() + timeoutMs;
115
+ let last = null;
116
+ for (;;) {
117
+ try { last = await status(messageId); } catch (error) { if (!last) last = { status: 'queued', error: error.code }; }
118
+ if (TERMINAL_STATUSES.has(last.status) || clock() >= deadline) return last;
119
+ await delay(Math.min(statusPollMs, Math.max(0, deadline - clock())));
120
+ }
121
+ }
122
+
123
+ function hasSent(messageId) {
124
+ return configured() && Boolean(nodeStore().sent(messageId));
125
+ }
126
+
127
+ async function localAuthority() {
128
+ return authorityFromProvider(await provider.getRemoteOriginAuthority(spaceId()));
129
+ }
130
+
131
+ function close() {
132
+ session?.client.close();
133
+ session = null;
134
+ store?.close();
135
+ store = null;
136
+ }
137
+
138
+ return { configured, isRemoteAddress, remoteAgents, resolveRemote, send, status, cancel, waitFor, hasSent, localAuthority,
139
+ nodeStore, directory, idFactory, close };
140
+ }
@@ -0,0 +1,298 @@
1
+ import { networkInterfaces } from 'node:os';
2
+ import { join as joinPath } from 'node:path';
3
+
4
+ import { resolvePersonalProjectPath } from '../graphiti/project-path-context.js';
5
+ import { loadProjectTeam } from '../graphiti/project-team-view.js';
6
+ import { startCoordinatorServer } from './coordinator-http.js';
7
+ import { cleanName, createCoordinatorService } from './coordinator-service.js';
8
+ import { createCoordinatorStore } from './coordinator-store.js';
9
+ import { loadDeviceIdentity } from './device-identity.js';
10
+ import { createPeerClient, pairWithCoordinator } from './peer-transport.js';
11
+ import { coordinatorUrl, decodeInvitation, PeerError, shortFingerprint } from './protocol.js';
12
+ import { createPeerRunner } from './runner.js';
13
+
14
+ const PUBLISH_MS = 60_000;
15
+ const WAKE_CLIENTS = new Set(['codex', 'claude_code']);
16
+
17
+ // The local owner's LAN roundtable controls, run only by the console process.
18
+ // Nothing here starts unless the owner turned it on; console LAN access does not.
19
+ export function createPeerRuntime({ app, port, interfaces = networkInterfaces, allowLoopback = false,
20
+ claimWaitMs, resolveFolder = resolvePersonalProjectPath, onError = () => {} }) {
21
+ const store = () => port.nodeStore();
22
+ let coordinator = null;
23
+ let runner = null;
24
+ let runnerClient = null;
25
+ let publisher = null;
26
+ let leaveRetry = null;
27
+ let leaveWork = null;
28
+ let leaveAbort = null;
29
+ const spaceId = () => app.config.personal.spaceId;
30
+
31
+ function localAddresses() {
32
+ return Object.values(interfaces()).flat().filter((entry) => entry && !entry.internal
33
+ && (entry.family === 'IPv4' || entry.family === 4)).map((entry) => entry.address);
34
+ }
35
+
36
+ async function resume() {
37
+ if (!port.configured()) return;
38
+ await finishLeaving();
39
+ const settings = store().setting('coordinator');
40
+ if (settings) await startCoordinator(settings);
41
+ if (store().setting('membership')) await startRunner();
42
+ }
43
+
44
+ async function startCoordinator({ host, port: listenPort, name }) {
45
+ const identity = await loadDeviceIdentity(port.directory);
46
+ const authority = await port.localAuthority();
47
+ const coordinatorStore = createCoordinatorStore(joinPath(port.directory, 'coordinator.sqlite'));
48
+ let server = null;
49
+ const service = createCoordinatorService({ store: coordinatorStore, identity, authority, name,
50
+ onTrustChange: () => server?.refreshTrust() });
51
+ try {
52
+ server = await startCoordinatorServer({ service, identity, host, port: listenPort, claimWaitMs });
53
+ } catch (error) {
54
+ coordinatorStore.close();
55
+ throw new PeerError('listen_failed', `Could not listen on ${host}:${listenPort}: ${error.code ?? error.message}`, 409);
56
+ }
57
+ coordinator = { service, server, store: coordinatorStore, host, name };
58
+ const url = coordinatorUrl(host, server.address.port);
59
+ store().setSetting('coordinator', { host, port: server.address.port, name });
60
+ store().setSetting('membership', { role: 'coordinator', coordinator: { url, certPem: identity.certPem,
61
+ fingerprint: identity.fingerprint, name } });
62
+ store().forgetAuthority(identity.fingerprint);
63
+ store().bindAuthority(identity.fingerprint, authority, 'self');
64
+ }
65
+
66
+ async function startRunner() {
67
+ if (runner) return;
68
+ const membership = store().setting('membership');
69
+ const identity = await loadDeviceIdentity(port.directory);
70
+ runnerClient = createPeerClient({ identity, coordinator: membership.coordinator });
71
+ runner = createPeerRunner({ store: store(), client: runnerClient, roundtable: app.roundtable, provider: app.personal,
72
+ nodeId: identity.fingerprint, spaceId, onError });
73
+ runner.start().catch(onError);
74
+ await publish().catch(onError);
75
+ publisher = setInterval(() => publish().catch(onError), PUBLISH_MS);
76
+ publisher.unref();
77
+ }
78
+
79
+ async function stopAll() {
80
+ clearTimeout(leaveRetry);
81
+ leaveRetry = null;
82
+ leaveAbort?.abort();
83
+ await leaveWork?.catch(onError);
84
+ clearInterval(publisher);
85
+ publisher = null;
86
+ await runner?.stop();
87
+ runner = null;
88
+ runnerClient?.close();
89
+ runnerClient = null;
90
+ if (coordinator) {
91
+ await coordinator.server.close();
92
+ coordinator.store.close();
93
+ coordinator = null;
94
+ }
95
+ }
96
+
97
+ // Each share names the lead it was made for; after a handoff the old share
98
+ // is withheld until the owner shares the project's new lead.
99
+ async function shareStates() {
100
+ const projects = await app.personal.listPersonalProjects(spaceId()).catch(() => []);
101
+ const names = new Map((Array.isArray(projects) ? projects : projects?.projects ?? [])
102
+ .map((project) => [project.project_id ?? project.personal_project_id ?? project.id, project.profile?.name ?? project.name]));
103
+ return Promise.all(store().shares().filter((share) => share.spaceId === spaceId()).map(async (share) => {
104
+ const team = await loadProjectTeam(app, share.spaceId, share.projectId).catch(() => null);
105
+ const current = team?.lead?.agentId === share.agentId;
106
+ return { ...share, projectName: names.get(share.projectId) ?? share.projectId, lead: team?.lead ?? null,
107
+ state: current ? 'shared' : team?.lead ? 'lead_changed' : 'lead_unavailable' };
108
+ }));
109
+ }
110
+
111
+ async function publish() {
112
+ if (!runnerClient) return;
113
+ const agents = (await shareStates()).filter((share) => share.state === 'shared').map((share) => ({
114
+ bindingId: share.bindingId, agentId: share.agentId, name: share.lead.name, employeeNumber: share.lead.employeeNumber,
115
+ responsibility: share.lead.responsibility, projectName: share.projectName, clients: share.clients }));
116
+ await runner?.reconcileShares(new Set(agents.map((agent) => agent.bindingId)));
117
+ await runnerClient.post('/peer/1/directory/publish', { agents });
118
+ }
119
+
120
+ // Projects the owner may share: each with its current lead and the clients that lead may use.
121
+ async function shareable() {
122
+ const projects = await app.personal.listPersonalProjects(spaceId()).catch(() => []);
123
+ const list = (Array.isArray(projects) ? projects : projects?.projects ?? [])
124
+ .filter((project) => project.scope_type !== 'temporary').slice(0, 50);
125
+ const agents = await app.listProjectAgents({ personalSpaceId: spaceId() }).catch(() => []);
126
+ return Promise.all(list.map(async (project) => {
127
+ const projectId = project.project_id ?? project.personal_project_id ?? project.id;
128
+ const team = await loadProjectTeam(app, spaceId(), projectId).catch(() => null);
129
+ const allowed = (agents.find((agent) => agent.agentId === team?.lead?.agentId)?.profile.allowedClients ?? [])
130
+ .filter((client) => WAKE_CLIENTS.has(client));
131
+ return { projectId, projectName: project.profile?.name ?? project.name ?? projectId,
132
+ lead: team?.lead ? { agentId: team.lead.agentId, name: team.lead.name } : null, clients: allowed };
133
+ }));
134
+ }
135
+
136
+ async function status() {
137
+ if (!port.configured()) {
138
+ return { beta: true, role: null, device: null, coordinator: null, addresses: localAddresses(), devices: [],
139
+ openInvitations: 0, shares: [], shareable: [] };
140
+ }
141
+ const membership = store().setting('membership');
142
+ const identity = membership ? await loadDeviceIdentity(port.directory) : null;
143
+ let devices = [];
144
+ if (coordinator) devices = coordinator.service.devices();
145
+ return {
146
+ beta: true,
147
+ remoteRemovalPending: Boolean(store().setting('pendingLeave')),
148
+ role: membership?.role ?? null,
149
+ device: identity ? { fingerprint: identity.fingerprint, shortFingerprint: shortFingerprint(identity.fingerprint) } : null,
150
+ coordinator: membership ? { name: membership.coordinator.name, url: membership.coordinator.url,
151
+ shortFingerprint: shortFingerprint(membership.coordinator.fingerprint), listening: Boolean(coordinator) } : null,
152
+ addresses: localAddresses(),
153
+ devices: devices.map((device) => ({ ...device, shortFingerprint: shortFingerprint(device.nodeId) })),
154
+ openInvitations: coordinator ? coordinator.service.openInvitations() : 0,
155
+ shares: membership ? (await shareStates()).map(({ lead: _lead, ...share }) => share) : [],
156
+ shareable: membership ? await shareable() : [],
157
+ };
158
+ }
159
+
160
+ async function enableCoordinator({ host, port: listenPort = 0, name }) {
161
+ if (store().setting('membership')) throw new PeerError('already_paired', 'Turn the LAN roundtable off before changing its role', 409);
162
+ const allowed = localAddresses();
163
+ if (!(allowed.includes(host) || (allowLoopback && host === '127.0.0.1'))) {
164
+ throw new PeerError('invalid_address', 'Choose one of this computer\'s network addresses', 422);
165
+ }
166
+ if (!Number.isSafeInteger(listenPort) || listenPort < 0 || listenPort > 65535) throw new PeerError('invalid_address', 'Invalid port', 422);
167
+ await startCoordinator({ host, port: listenPort, name: cleanName(name) || 'FULI coordinator' });
168
+ await startRunner();
169
+ return status();
170
+ }
171
+
172
+ function createInvitation() {
173
+ if (!coordinator) throw new PeerError('not_coordinator', 'Only the coordinator device creates invitations', 409);
174
+ const settings = store().setting('coordinator');
175
+ return coordinator.service.createInvitation({ url: coordinatorUrl(settings.host, settings.port) });
176
+ }
177
+
178
+ function previewInvitation(text) {
179
+ const invitation = decodeInvitation(text);
180
+ return { name: invitation.name, url: invitation.url, fingerprint: invitation.fingerprint,
181
+ shortFingerprint: shortFingerprint(invitation.fingerprint), expiresAt: invitation.expiresAt };
182
+ }
183
+
184
+ async function join({ invitation: text, name }) {
185
+ if (store().setting('membership')) throw new PeerError('already_paired', 'This device is already paired', 409);
186
+ await finishLeaving();
187
+ if (store().setting('pendingLeave')) throw new PeerError('leave_pending', 'The previous coordinator is offline. Retry after it is available, or revoke this device there.', 409);
188
+ const invitation = decodeInvitation(text);
189
+ const identity = await loadDeviceIdentity(port.directory);
190
+ const authority = await port.localAuthority();
191
+ const result = await pairWithCoordinator({ identity, authority, invitation, name: cleanName(name) || 'FULI device' });
192
+ if (result.coordinatorNodeId !== invitation.fingerprint) throw new PeerError('invalid_pairing', 'The coordinator answered with another identity', 502);
193
+ store().forgetAuthority(invitation.fingerprint);
194
+ store().bindAuthority(invitation.fingerprint, invitation.authority, 'pairing');
195
+ store().setSetting('membership', { role: 'member', coordinator: { url: invitation.url, certPem: invitation.certificatePem,
196
+ fingerprint: invitation.fingerprint, name: invitation.name || 'FULI coordinator' } });
197
+ await startRunner();
198
+ return status();
199
+ }
200
+
201
+ // The owner picks each project, the clients it may be woken in and,
202
+ // optionally, its folder; each share is bound to that project's current lead.
203
+ async function setShares({ shares: wanted }) {
204
+ if (!Array.isArray(wanted) || wanted.length > 50) throw new PeerError('invalid_shares', 'Choose up to 50 projects', 422);
205
+ const projects = await app.personal.listPersonalProjects(spaceId());
206
+ const known = Array.isArray(projects) ? projects : projects?.projects ?? [];
207
+ const agents = await app.listProjectAgents({ personalSpaceId: spaceId() });
208
+ const shares = [];
209
+ for (const entry of wanted) {
210
+ const projectId = entry?.projectId;
211
+ if (typeof projectId !== 'string' || !projectId || shares.some((share) => share.projectId === projectId)) {
212
+ throw new PeerError('invalid_shares', 'Each shared project must be listed once', 422);
213
+ }
214
+ const team = await loadProjectTeam(app, spaceId(), projectId);
215
+ if (!team.lead) throw new PeerError('lead_unavailable', 'A shared project needs an active project lead', 409);
216
+ const allowed = (agents.find((agent) => agent.agentId === team.lead.agentId)?.profile.allowedClients ?? [])
217
+ .filter((client) => WAKE_CLIENTS.has(client));
218
+ const clients = Array.isArray(entry.clients) ? [...new Set(entry.clients)] : [];
219
+ if (!clients.length || !clients.every((client) => allowed.includes(client))) {
220
+ throw new PeerError('invalid_clients', 'Choose at least one client the project lead may use', 422);
221
+ }
222
+ shares.push({ spaceId: spaceId(), projectId, agentId: team.lead.agentId, clients,
223
+ workingDirectory: entry.workingDirectory ? confirmedFolder(entry.workingDirectory, projectId, known) : null });
224
+ }
225
+ store().replaceShares(shares, port.idFactory);
226
+ await runner?.reconcileShares();
227
+ await publish().catch(onError);
228
+ return status();
229
+ }
230
+
231
+ // A folder is accepted only when FULI's own project resolution maps it to this project.
232
+ function confirmedFolder(folder, projectId, projects) {
233
+ let resolved;
234
+ try { resolved = resolveFolder(folder, projects); }
235
+ catch { throw new PeerError('invalid_folder', 'Choose an existing folder of this project', 422); }
236
+ if (resolved.personalProjectId !== projectId) {
237
+ throw new PeerError('invalid_folder', 'That folder does not belong to this project', 422);
238
+ }
239
+ return folder;
240
+ }
241
+
242
+ async function revokeDevice(nodeId) {
243
+ if (!coordinator) throw new PeerError('not_coordinator', 'Only the coordinator device revokes devices', 409);
244
+ coordinator.service.revoke(nodeId);
245
+ store().forgetAuthority(nodeId);
246
+ return status();
247
+ }
248
+
249
+ // Leaves the LAN roundtable. History stays; trust, sharing and listening end.
250
+ async function disable() {
251
+ const membership = store().setting('membership');
252
+ // A coordinator turned on again later must not silently trust old devices.
253
+ for (const device of coordinator?.service.devices() ?? []) {
254
+ if (!device.self && device.status === 'active') coordinator.service.revoke(device.nodeId);
255
+ }
256
+ await stopAll();
257
+ store().replaceShares([], port.idFactory);
258
+ store().forgetAuthorities();
259
+ store().setSetting('membership', null);
260
+ store().setSetting('coordinator', null);
261
+ if (membership?.role === 'member') store().setSetting('pendingLeave', membership.coordinator);
262
+ await finishLeaving();
263
+ return status();
264
+ }
265
+
266
+ // Local execution stops even if the coordinator is offline. Keep only the
267
+ // unpairing request until it can be delivered, without resuming any shares.
268
+ function finishLeaving() {
269
+ if (leaveWork) return leaveWork;
270
+ leaveWork = deliverLeave().finally(() => { leaveWork = null; leaveAbort = null; });
271
+ return leaveWork;
272
+ }
273
+
274
+ async function deliverLeave() {
275
+ clearTimeout(leaveRetry);
276
+ leaveRetry = null;
277
+ const previous = store().setting('pendingLeave');
278
+ if (!previous) return;
279
+ leaveAbort = new AbortController();
280
+ const identity = await loadDeviceIdentity(port.directory);
281
+ const client = createPeerClient({ identity, coordinator: previous });
282
+ try {
283
+ await client.post('/peer/1/leave', {}, { timeoutMs: 2_000, signal: leaveAbort.signal });
284
+ store().setSetting('pendingLeave', null);
285
+ } catch (error) {
286
+ if (leaveAbort.signal.aborted) return;
287
+ if (error.status === 403) store().setSetting('pendingLeave', null);
288
+ else {
289
+ onError(error);
290
+ leaveRetry = setTimeout(() => finishLeaving().catch(onError), PUBLISH_MS);
291
+ leaveRetry.unref();
292
+ }
293
+ } finally { client.close(); }
294
+ }
295
+
296
+ return { resume, status, enableCoordinator, createInvitation, previewInvitation, join, setShares, revokeDevice, disable,
297
+ publish, close: stopAll, runnerActive: () => Boolean(runner) };
298
+ }
@@ -0,0 +1,78 @@
1
+ import { Agent, request as httpsRequest } from 'node:https';
2
+
3
+ import { PEER_LIMITS, PeerError, parseCoordinatorUrl, sha256Hex } from './protocol.js';
4
+
5
+ // Talks to exactly one coordinator: trusted only through its pinned
6
+ // certificate, never redirected, and always presenting this device's certificate.
7
+ export function createPeerClient({ identity, coordinator }) {
8
+ const origin = parseCoordinatorUrl(coordinator.url);
9
+ const tls = pinnedTls(identity, coordinator);
10
+ const agent = new Agent({ ...tls, keepAlive: true, maxSockets: 4 });
11
+ return {
12
+ post: (path, body, options = {}) => post(origin, path, body, { ...options, agent }),
13
+ close: () => agent.destroy(),
14
+ };
15
+ }
16
+
17
+ // Pairing uses its own connection: the coordinator only trusts this device after it succeeds.
18
+ export function pairWithCoordinator({ identity, authority, invitation, name, timeoutMs = 15_000 }) {
19
+ const coordinator = { url: invitation.url, certPem: invitation.certificatePem, fingerprint: invitation.fingerprint };
20
+ return post(parseCoordinatorUrl(invitation.url), '/peer/1/pair', { code: invitation.code, name, authority },
21
+ { agent: false, tls: pinnedTls(identity, coordinator), timeoutMs });
22
+ }
23
+
24
+ function pinnedTls(identity, coordinator) {
25
+ return {
26
+ ...identity.tlsOptions,
27
+ ca: [coordinator.certPem],
28
+ minVersion: 'TLSv1.3',
29
+ checkServerIdentity: (_host, certificate) => (certificate?.raw && sha256Hex(certificate.raw) === coordinator.fingerprint
30
+ ? undefined : new PeerError('fingerprint_mismatch', 'The coordinator certificate does not match the paired fingerprint', 502)),
31
+ };
32
+ }
33
+
34
+ function post(origin, path, body, { agent, tls = {}, timeoutMs = 15_000, signal } = {}) {
35
+ const url = new URL(path, origin);
36
+ const payload = Buffer.from(JSON.stringify(body ?? {}));
37
+ if (payload.length > PEER_LIMITS.requestBytes) return Promise.reject(new PeerError('payload_too_large', 'Request body is too large', 413));
38
+ return new Promise((resolve, reject) => {
39
+ let settled = false;
40
+ const settle = (error, value) => {
41
+ if (settled) return;
42
+ settled = true;
43
+ clearTimeout(deadline);
44
+ if (error) { request.destroy(); reject(error); } else resolve(value);
45
+ };
46
+ // An absolute deadline covers slow headers, a stalled body and a half-closed TLS stream alike.
47
+ const deadline = setTimeout(() => settle(new PeerError('coordinator_timeout', 'The coordinator did not respond in time', 504)), timeoutMs);
48
+ const request = httpsRequest(url, { method: 'POST', agent, ...tls, signal,
49
+ headers: { 'content-type': 'application/json', 'content-length': payload.length } }, (response) => {
50
+ let size = 0;
51
+ const chunks = [];
52
+ response.on('data', (chunk) => {
53
+ size += chunk.length;
54
+ if (size > PEER_LIMITS.responseBytes) settle(new PeerError('response_too_large', 'Coordinator response is too large', 502));
55
+ else chunks.push(chunk);
56
+ });
57
+ response.on('end', () => {
58
+ let value;
59
+ try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { value = undefined; }
60
+ const object = value && typeof value === 'object' && !Array.isArray(value) ? value : null;
61
+ if (response.statusCode >= 200 && response.statusCode < 300) {
62
+ return object ? settle(null, object) : settle(new PeerError('invalid_response', 'The coordinator sent an invalid response', 502));
63
+ }
64
+ settle(new PeerError(typeof object?.error === 'string' ? object.error : 'coordinator_error',
65
+ typeof object?.message === 'string' ? object.message : `Coordinator responded ${response.statusCode}`, response.statusCode));
66
+ });
67
+ const interrupted = () => settle(new PeerError('response_interrupted', 'The coordinator response was interrupted', 502));
68
+ response.on('aborted', interrupted);
69
+ response.on('error', interrupted);
70
+ response.on('close', () => { if (!response.complete) interrupted(); });
71
+ });
72
+ request.on('error', (error) => settle(error instanceof PeerError ? error
73
+ : error.code === 'ABORT_ERR' ? new PeerError('aborted', 'The request was cancelled', 499)
74
+ : /CERT|SELF_SIGNED|DEPTH|SSL|TLS|ECONNRESET/.test(String(error.code)) ? new PeerError('tls_rejected', 'The secure connection was rejected', 502)
75
+ : new PeerError('coordinator_unreachable', 'The coordinator is unreachable', 503)));
76
+ request.end(payload);
77
+ });
78
+ }